006-视频H264解码详解(下)

1,319 阅读21分钟

前言

本篇接着005 - 视频H264编码详解(中),主要做H264编解码流程中的最后2环 👇

  1. 继续封装解码工具类
  2. 拿到解码的流数据之后,渲染显示视频帧画面

一、初始化

初始化分为2个方法执行👇🏻

  • 工具类本身的对外公开的初始化方法 👉🏻 - (instancetype)initWithConfig:(CCVideoConfig*)config;
  • 解码器的初始化 👉🏻 这是在解码的时候才做的事情!
  1. 和编码工具类一样,也是2个异步队列分别做解码回调👇🏻
@property (nonatomic, strong) dispatch_queue_t decodeQueue;

@property (nonatomic, strong) dispatch_queue_t callbackQueue;
  1. 解码器的初始化和编码器一样,需要解码session👇🏻
@property (nonatomic) VTDecompressionSessionRef decodeSesion;
  1. 解码session的创建函数,需依赖SPS/PPS等关键帧的信息,然后输出一个视频帧格式描述CMVideoFormatDescriptionRef,所以还需要定义以下成员变量👇🏻
@implementation CCVideoDecoder{
    uint8_t *_sps;
    NSUInteger _spsSize;
    uint8_t *_pps;
    NSUInteger _ppsSize;
    CMVideoFormatDescriptionRef _decodeDesc; // 视频输出格式
}

1.1 工具类的初始化

首先是工具类的初始化,和编码器工具类一样,都是依赖配置类CCVideoConfig👇🏻

- (instancetype)initWithConfig:(CCVideoConfig *)config {
    self = [super init];
    if (self) {
        //初始化VideoConfig 信息
        _config = config;

        //创建解码队列与回调队列
        _decodeQueue = dispatch_queue_create("h264 hard decode queue", DISPATCH_QUEUE_SERIAL);
        _callbackQueue = dispatch_queue_create("h264 hard decode callback queue", DISPATCH_QUEUE_SERIAL);
    }
    return self;
}

1.2 解码器的初始化

接着是编码器的初始化,包括2部分:创建 + 配置

1.2.1 相关函数

解码session的创建比编码器的稍微复杂点,包括3部分内容👇🏻

  1. 根据sps pps设置解码的视频输出格式

使用函数CMVideoFormatDescriptionCreateFromH264ParameterSets👇🏻

image.png

其参数释义如下👇🏻

  • 参数1: kCFAllocatorDefault 分配器
  • 参数2: 2个 参数个数
  • 参数3: parameterSetPointers 参数集指针
  • 参数4: parameterSetSizes 参数集大小
  • 参数5: NALUnitHeaderLength 起始位的长度 长度为4
  • 参数6: _decodeDesc 解码器描述
  1. 解码器参数的配置

常用的解码器参数有以下几个👇🏻

  • kCVPixelBufferPixelFormatTypeKey:摄像头的输出数据格式,已测可用值为
    • kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,即420v
    • kCVPixelFormatType_420YpCbCr8BiPlanarFullRange,即420f
    • kCVPixelFormatType_32BGRA,iOS在内部进行YUV至BGRA格式转换

YUV420一般用于标清视频,YUV422用于高清视频,这里的限制让人感到意外。但是,在相同条件下,YUV420计算耗时和传输压力比YUV422都小。

  • kCVPixelBufferWidthKey/kCVPixelBufferHeightKey: 视频源的分辨率width*height
  • kCVPixelBufferOpenGLCompatibilityKey: 它允许在 OpenGL 的上下文中直接绘制解码后的图像,而不是从总线和 CPU 之间复制数据。这有时候被称为零拷贝通道,因为在绘制过程中没有解码的图像被拷贝。
  1. 解码器的回调设置

编码器的回调是在创建session时所指定的函数指针,但是解码器的回调却不同,解码器的回调是一个简单的结构体VTDecompressionOutputCallbackRecord👇🏻

image.png

它带有一个指针decompressionOutputCallback,指向帧解压完成后的回调方法,还需要提供可以找到这个回调方法的实例decompressionOutputRefCon。其中VTDecompressionOutputCallback定义如下 👇🏻

image.png

回调方法包括七个参数 👇🏻

  • 参数1: 回调的引用
  • 参数2: 帧的引用
  • 参数3: 一个状态标识 (包含未定义的代码)
  • 参数4: 指示同步/异步解码,或者解码器是否打算丢帧的标识
  • 参数5: 实际图像的缓冲
  • 参数6: 出现的时间戳
  • 参数7: 出现的持续时间

最后,就是解码session创建函数👇🏻

image.png

创建用于解压缩视频帧的会话,解压后的帧将通过调用OutputCallback发出,参数包括 👇🏻

  • 参数1: allocator 内存的会话。使用默认的kCFAllocatorDefault
  • 参数2: videoFormatDescription 描述源视频帧
  • 参数3: videoDecoderSpecification 指定必须使用的特定视频解码器.NULL
  • 参数4: destinationImageBufferAttributes 描述源像素缓冲区的要求 NULL
  • 参数5: outputCallback 使用已解压缩的帧调用的回调
  • 参数6: decompressionSessionOut 指向一个变量以接收新的解压会话

1.2.2 完整版代码

- (BOOL)initDecoder {
    // 保证解码器只初始化一次
    if (_decodeSesion) {
        return true;
    }
    
    const uint8_t * const parameterSetPointers[2] = {_sps, _pps};
    const size_t parameterSetSizes[2] = {_spsSize, _ppsSize};
    int naluHeaderLen = 4;
    //根据sps pps设置解码视频输出格式
    OSStatus status = CMVideoFormatDescriptionCreateFromH264ParameterSets(kCFAllocatorDefault, 2, parameterSetPointers, parameterSetSizes, naluHeaderLen, &_decodeDesc);
    if (status != noErr) {
        NSLog(@"Video hard DecodeSession create H264ParameterSets(sps, pps) failed status= %d", (int)status);
        return false;
    }
    
    //解码参数
    NSDictionary *destinationPixBufferAttrs =
    @{
      (id)kCVPixelBufferPixelFormatTypeKey: [NSNumber numberWithInt:kCVPixelFormatType_420YpCbCr8BiPlanarFullRange], //iOS上 nv12(uvuv排布) 而不是nv21(vuvu排布)
      (id)kCVPixelBufferWidthKey: [NSNumber numberWithInteger:_config.width],
      (id)kCVPixelBufferHeightKey: [NSNumber numberWithInteger:_config.height],
      (id)kCVPixelBufferOpenGLCompatibilityKey: [NSNumber numberWithBool:true]
      };
    
    //解码回调设置
    VTDecompressionOutputCallbackRecord callbackRecord;
    callbackRecord.decompressionOutputCallback = videoDecompressionOutputCallback;
    callbackRecord.decompressionOutputRefCon = (__bridge void * _Nullable)(self);
    
    //创建session
    status = VTDecompressionSessionCreate(kCFAllocatorDefault, _decodeDesc, NULL, (__bridge CFDictionaryRef _Nullable)(destinationPixBufferAttrs), &callbackRecord, &_decodeSesion);
    
    //判断一下status
    if (status != noErr) {
        NSLog(@"Video hard DecodeSession create failed status= %d", (int)status);
        return false;
    }
    
    //设置解码会话属性(实时编码)
    status = VTSessionSetProperty(_decodeSesion, kVTDecompressionPropertyKey_RealTime,kCFBooleanTrue);
    
    NSLog(@"Vidoe hard decodeSession set property RealTime status = %d", (int)status);
    
    return true;
}

二、解码 & 回调

2.1 解码流程

之前我们定义了一个public解码方法 👇

- (void)decodeNaluData:(NSData *)frame {
    //将解码放在异步队列.
    dispatch_async(_decodeQueue, ^{
        //获取frame 二进制数据,将数据拆解
        uint8_t *nalu = (uint8_t *)frame.bytes;

        //调用解码Nalu数据方法,参数1:数据 参数2:数据长度
        [self decodeNaluData:nalu size:(uint32_t)frame.length];
    });
}

解码Nalu流数据的过程,单独放在了decodeNaluData:size:方法里 👇🏻

- (void)decodeNaluData:(uint8_t *)frame size:(uint32_t)size {
    //数据类型:frame的前4个字节是NALU数据的开始码,也就是00 00 00 01,
    //第5个字节是表示数据类型type,转为10进制后,7是sps, 8是pps, 5是IDR(I帧)信息
    int type = (frame[4] & 0x1F);

    // 将NALU的开始码转为4字节大端NALU的长度信息
    uint32_t naluSize = size - 4;
    uint8_t *pNaluSize = (uint8_t *)(&naluSize);
    CVPixelBufferRef pixelBuffer = NULL;
    frame[0] = *(pNaluSize + 3);
    frame[1] = *(pNaluSize + 2);
    frame[2] = *(pNaluSize + 1);
    frame[3] = *(pNaluSize);

    //第一次解析时: 初始化解码器initDecoder
    /*
     关键帧/其他帧数据: 调用[self decode:frame withSize:size] 方法
     sps/pps数据:则将sps/pps数据赋值到_sps/_pps中.
     */

    switch (type) {
        case 0x05: //关键帧
            if ([self initDecoder]) {
                pixelBuffer= [self decode:frame withSize:size];
            }
            break;
        case 0x06:
            //NSLog(@"SEI");//增强信息
            break;
        case 0x07: //sps memcpy保存起来
            _spsSize = naluSize;
            _sps = malloc(_spsSize);
            memcpy(_sps, &frame[4], _spsSize);
            break;
        case 0x08: //pps memcpy保存起来
            _ppsSize = naluSize;
            _pps = malloc(_ppsSize);
            memcpy(_pps, &frame[4], _ppsSize);
            break;
        default: //其他帧(1-5)
            if ([self initDecoder]) {
                pixelBuffer = [self decode:frame withSize:size];
            }
            break;
    }
}

之所以定义decodeNaluData:size:这个方法,就是清晰解码的流程,该方法通过switch-case方式单独处理每一帧的流数据,先判断是什么类型帧,再单独做处理👇🏻

  • sps和pps: 不解码,只缓存 👉🏻 初始化解码器会用到
  • 关键帧和其他非关键帧: 解码前需先判断解码器初始化是否完成

接下来就是核心的解码流程,之前我们分析过,知道解码涉及了2个数据结构👇🏻

image.png

  • CVPixelBufferRef 👉🏻 编码之前 / 解码之后的数据
  • CMBlockBufferRef 👉🏻 编码之后的数据

解码的流程,我们封装在方法decode:withSize:中 👇🏻

- (CVPixelBufferRef)decode:(uint8_t *)frame withSize:(uint32_t)frameSize {
    CVPixelBufferRef outputPixelBuffer = NULL;
    CMBlockBufferRef blockBuffer = NULL;
    CMBlockBufferFlags flag0 = 0;
    
    //创建blockBuffer
    OSStatus status = CMBlockBufferCreateWithMemoryBlock(kCFAllocatorDefault, frame, frameSize, kCFAllocatorNull, NULL, 0, frameSize, flag0, &blockBuffer);
    if (status != kCMBlockBufferNoErr) {
        NSLog(@"Video hard decode create blockBuffer error code=%d", (int)status);
        return outputPixelBuffer;
    }
    
    CMSampleBufferRef sampleBuffer = NULL;
    const size_t sampleSizeArray[] = {frameSize};
    
    //创建sampleBuffer
    status = CMSampleBufferCreateReady(kCFAllocatorDefault, blockBuffer, _decodeDesc, 1, 0, NULL, 1, sampleSizeArray, &sampleBuffer);
    if (status != noErr || !sampleBuffer) {
        NSLog(@"Video hard decode create sampleBuffer failed status=%d", (int)status);
        CFRelease(blockBuffer);
        return outputPixelBuffer;
    }
    
    //解码
    
    //向视频解码器提示使用低功耗模式是可以的
    VTDecodeFrameFlags flag1 = kVTDecodeFrame_1xRealTimePlayback;
    
    //异步解码
    VTDecodeInfoFlags  infoFlag = kVTDecodeInfo_Asynchronous;
    
    //解码数据
    status = VTDecompressionSessionDecodeFrame(_decodeSesion, sampleBuffer, flag1, &outputPixelBuffer, &infoFlag);
    if (status == kVTInvalidSessionErr) {
        NSLog(@"Video hard decode  InvalidSessionErr status =%d", (int)status);
    } else if (status == kVTVideoDecoderBadDataErr) {
        NSLog(@"Video hard decode  BadData status =%d", (int)status);
    } else if (status != noErr) {
        NSLog(@"Video hard decode failed status =%d", (int)status);
    }

    CFRelease(sampleBuffer);
    CFRelease(blockBuffer);
    
    return outputPixelBuffer;
}

以上方法,可以看出解码的过程👇🏻

  • (uint8_t *)frame --> CMBlockBufferRef --> CMSampleBufferRef
  • 解码器只认CMSampleBufferRef,且解码后的数据存储在CVPixelBufferRef

2.2 解码回调函数的流程

接下来,我们看看解码回调函数中做了什么?

void videoDecompressionOutputCallback(void * CM_NULLABLE decompressionOutputRefCon,
                                                                      void * CM_NULLABLE sourceFrameRefCon,
                                                                      OSStatus status,
                                                                      VTDecodeInfoFlags infoFlags,
                                                                      CM_NULLABLE CVImageBufferRef imageBuffer,
                                                                      CMTime presentationTimeStamp,
                                                                      CMTime presentationDuration ) {
    if (status != noErr) {
        NSLog(@"Video hard decode callback error status=%d", (int)status);
        return;
    }
    
    //解码后的数据sourceFrameRefCon -> CVPixelBufferRef
    CVPixelBufferRef *outputPixelBuffer = (CVPixelBufferRef *)sourceFrameRefCon;
    *outputPixelBuffer = CVPixelBufferRetain(imageBuffer);
    
    //获取self
    CCVideoDecoder *decoder = (__bridge CCVideoDecoder *)(decompressionOutputRefCon);

    //调用回调队列
    dispatch_async(decoder.callbackQueue, ^{
        //将解码后的数据给decoder代理.viewController
        [decoder.delegate videoDecodeCallback:imageBuffer];

        //释放数据
        CVPixelBufferRelease(imageBuffer);
    });
}

流程很简单,拿到解码后的数据CVPixelBufferRef,再在回调队列中异步delegate输出数据。

至此,整个解码工具类的封装完毕。

三、渲染显示

最后,就是显示流数据了,其实是将CVPixelBufferRef中的数据显示到屏幕上,此时我们需要使用OpenGL ES,它是专门做图形/图片纹理渲染的,OpenGL ES默认的颜色体系是RGB,但是CVPixelBufferRef中的颜色配置的是YUV 4:2:0,所以需要做个转换 👉🏻 YUV --> RGB

YUV模式中 Y表示亮度,也就是灰阶值,它是基础信号,而U和V表示的则是色度,UV的作用是描述影像的色彩及饱和度,它们用于指定像素的颜色。所以,只有Y是可以显示图像的,只不过是黑白色的,有了UV的加持,就变成彩色的了,因此,我们可以推断出 👉🏻 视频由2个图层组成:Y图层纹理+UV图层纹理,那么👇🏻

视频的渲染-->纹理的渲染-->片元着色器填充-->width*height正方形(渲染2个纹理)

再回到代码部分,我们最终在解码回调中,将解码后的数据delegate到viewController中👇🏻

//h264解码回调

- (void)videoDecodeCallback:(CVPixelBufferRef)imageBuffer {
    //显示
    if (imageBuffer) {
        _displayLayer.pixelBuffer = imageBuffer;
    }
}

这个_displayLayerAAPLEAGLLayer👇🏻

@property (nonatomic, strong) AAPLEAGLLayer *displayLayer;

AAPLEAGLLayer是继承CAEAGLLayer的,而CAEAGLLayer是iOS原生库QuartzCore里的👇🏻

image.png 所以CAEAGLLayer只是个图层,它是iOS macOS提供的一个专门用来渲染OpenGL ES的图层继承CALayer 👇🏻

image.png

OpenGL ES它是负责核心的渲染动作,至于交给谁去显示(比如Layer、比如view),OpenGL ES并不关心,这个是由编译器去决定的,这个就是OpenGL ES跨平台的核心,不被任何系统所约束!

最后,我们来看看图层类AAPLEAGLLayer这块的封装,图层显示数据,无非就是初始化 + 渲染这2个主要流程!

3.1 初始化

首先看看初始化,主要就是图层类的初始化 和 OpenGL的初始化。

3.1.1 图层的初始化

@interface AAPLEAGLLayer : CAEAGLLayer

@property CVPixelBufferRef pixelBuffer;

- (id)initWithFrame:(CGRect)frame;
- (void)resetRenderBuffer;

@end

AAPLEAGLLayer提供的初始化方法就是- (id)initWithFrame:(CGRect)frame;,调用的地方(ViewController.m)是这么写👇🏻

//显示解码后的数据 -> OpenGL ES
CGSize size = CGSizeMake(self.view.frame.size.width/2, self.view.frame.size.height/2);//分辨率
_displayLayer = [[AAPLEAGLLayer alloc] initWithFrame:CGRectMake(size.width, 100, size.width, size.height)];
[self.view.layer addSublayer:_displayLayer];

初始化方法的实现 👇🏻

- (instancetype)initWithFrame:(CGRect)frame
{
    self = [super init];
    if (self) {
        // scale
        CGFloat scale = [[UIScreen mainScreen] scale];
        self.contentsScale = scale;
        // 透明度
        self.opaque = TRUE;
        // kEAGLDrawablePropertyRetainedBacking 👉🏻视频绘制完成后是否需要保留其内容
        self.drawableProperties = @{ kEAGLDrawablePropertyRetainedBacking :[NSNumber numberWithBool:YES]};
        
        [self setFrame:frame];
        
        //上下文 Set the context into which the frames will be drawn.
        _context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
        if (!_context) {
            return nil;
        }
        
        //指定默认的颜色转换类型 HDTV标准BT.709
        _preferredConversion = kColorConversion709;
        
        //初始化OpenGL
        [self setupGL];
    }
    return self;
}

3.1.2 OpenGL的初始化

接下来就是OpenGL的初始化,需要定义一些成员变量,为初始化做准备 👇🏻

@interface AAPLEAGLLayer ()
{
    //渲染缓存区的宽和高
    GLint _backingWidth;
    GLint _backingHeight;
    //上下文:用来判断图层是否初始化成功
    EAGLContext *_context;
    //2个纹理:亮度纹理 和 色度纹理,渲染显示时使用
    CVOpenGLESTextureRef _lumaTexture;
    CVOpenGLESTextureRef _chromaTexture;
    
    //缓存区:帧缓存区/渲染缓存区,
    GLuint _frameBufferHandle;
    GLuint _colorBufferHandle;
    
    //所需要的颜色标准,如kColorConversion601/kColorConversion709
    const GLfloat *_preferredConversion;
}

接着看setupGL方法的实现👇🏻

- (void)setupGL
{
    if (!_context || ![EAGLContext setCurrentContext:_context]) {
        return;
    }
    //1.设置缓存区
    [self setupBuffers];
    //2.加载Shaders
    [self loadShaders];
    //3.使用program
    glUseProgram(self.program);
    
    //4.设置相关的参数 👉🏻 Uniform
    // 0 and 1 are the texture IDs of _lumaTexture and _chromaTexture respectively.
    glUniform1i(uniforms[UNIFORM_Y], 0);
    glUniform1i(uniforms[UNIFORM_UV], 1);
    glUniform1f(uniforms[UNIFORM_ROTATION_ANGLE], 0);
    glUniformMatrix3fv(uniforms[UNIFORM_COLOR_CONVERSION_MATRIX], 1, GL_FALSE, _preferredConversion);
}
3.1.2.1 配置Uniform相关参数

上述代码中,我们看到是通过glUniform1i方法配置的Uniform相关参数,其中,key是uniforms数组中的元素,后面的0或1是value,最后又通过glUniformMatrix3fv处理颜色转换矩阵,这个后面会细讲。

uniforms数组定义如下👇🏻

// Uniform index.
enum
{
    UNIFORM_Y, //Y纹理
    UNIFORM_UV,//UV纹理
    UNIFORM_ROTATION_ANGLE,//渲染角度
    UNIFORM_COLOR_CONVERSION_MATRIX,//颜色变换矩阵
    NUM_UNIFORMS
};
GLint uniforms[NUM_UNIFORMS];

这是OpenGL ES中,配置uniforms相关的一些常用参数。

3.1.2.2 设置缓存区

接着看看setupBuffers设置缓存区方法的实现👇🏻

- (void)setupBuffers
{
    //取消深度测试 深度问题 👉🏻 一个时间点只显示一张图片
    glDisable(GL_DEPTH_TEST);
    //配置顶点信息
    glEnableVertexAttribArray(ATTRIB_VERTEX);
    glVertexAttribPointer(Attribute index, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(GLfloat), 0);
    //配置纹理坐标信息(x, y)
    glEnableVertexAttribArray(ATTRIB_TEXCOORD);
    glVertexAttribPointer(ATTRIB_TEXCOORD, 2, GL_FLOAT, GL_FALSE, 2 * sizeof(GLfloat), 0);
    //创建buffer
    [self createBuffers];
}

缓存区分帧缓存区渲染缓存区,它们的创建和使用时机👇🏻

  1. 初始化OpenGL时,需要创建缓存区
  2. 渲染显示数据时,需要从缓存区里取出数据
  • Attribute index 设置缓存区的时候,我们使用到了ATTRIB_VERTEXATTRIB_TEXCOORD,它们的定义如下👇🏻
enum
{
    ATTRIB_VERTEX,//顶点坐标
    ATTRIB_TEXCOORD,//纹理坐标
    NUM_ATTRIBUTES
};
  • 创建buffer 接着就是createBuffers buffer的创建过程👇🏻
- (void) createBuffers
{
    //创建 帧缓存区
    glGenFramebuffers(1, &_frameBufferHandle);
    glBindFramebuffer(GL_FRAMEBUFFER, _frameBufferHandle);
    //创建 渲染缓存区
    glGenRenderbuffers(1, &_colorBufferHandle);
    glBindRenderbuffer(GL_RENDERBUFFER, _colorBufferHandle);
    [_context renderbufferStorage:GL_RENDERBUFFER fromDrawable:self];
    //设置渲染缓存区的宽/高
    glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &_backingWidth);
    glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &_backingHeight);
    //设置渲染缓存区的 颜色挂载点 和 目标类型
    glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _colorBufferHandle);
    if (glCheckFramebufferStatus(GL_FRAMEBUFFER) != GL_FRAMEBUFFER_COMPLETE) {
        NSLog(@"Failed to make complete framebuffer object %x", glCheckFramebufferStatus(GL_FRAMEBUFFER));
    }
}
  • 释放buffer 有创建,当然也有释放releaseBuffers 👇🏻
- (void)releaseBuffers
{
    if(_frameBufferHandle) {
        glDeleteFramebuffers(1, &_frameBufferHandle);
        _frameBufferHandle = 0;
    }
    
    if(_colorBufferHandle) {
        glDeleteRenderbuffers(1, &_colorBufferHandle);
        _colorBufferHandle = 0;
    }
}

其实就是删除_frameBufferHandle和_colorBufferHandle。

3.1.2.3 加载Shaders

loadShaders(加载Shaders)中,主要是针对片元着色器顶点着色器的处理 👇🏻

编译 + 链接 + uniforms的连接

首先需要定义program👇🏻

@property GLuint program;

接着编译片元着色器顶点着色器,方法如下👇🏻

- (BOOL)compileShaderString:(GLuint *)shader type:(GLenum)type shaderString:(const GLchar*)shaderString
{
    //创建Shader,绑定Source,编译shader
    *shader = glCreateShader(type);
    glShaderSource(*shader, 1, &shaderString, NULL);
    glCompileShader(*shader);
    
    //接下来,就是获取shader的状态信息,有错就打印出来,一切正常,最终返回YES
#if defined(DEBUG)
    GLint logLength;
    glGetShaderiv(*shader, GL_INFO_LOG_LENGTH, &logLength);
    if (logLength > 0) {
        GLchar *log = (GLchar *)malloc(logLength);
        glGetShaderInfoLog(*shader, logLength, &logLength, log);
        NSLog(@"Shader compile log:\n%s", log);
        free(log);
    }
#endif
    
    GLint status = 0;
    glGetShaderiv(*shader, GL_COMPILE_STATUS, &status);
    if (status == 0) {
        glDeleteShader(*shader);
        return NO;
    }
    
    return YES;
}
  • 通过参数(GLenum)type区分着色器,是片元着色器还是顶点着色器
  • 参数(GLuint *)shader:用来接收生成的着色器
  • 参数(const GLchar*)shaderString:配置着色器生成规则的字符串

熟悉OpenGL ES的同学应该能写出着色器生成规则,如果是第一次接触,可以先尝试百度搜索了解一下先。我这里就示例写一下👇🏻

  1. 顶点着色器
const GLchar *shader_vsh = (const GLchar*)"attribute vec4 position;"
"attribute vec2 texCoord;"
"uniform float preferredRotation;"
"varying vec2 texCoordVarying;"
"void main()"
"{"
"    mat4 rotationMatrix = mat4(cos(preferredRotation), -sin(preferredRotation), 0.0, 0.0,"
"                               sin(preferredRotation),  cos(preferredRotation), 0.0, 0.0,"
"                               0.0,                        0.0, 1.0, 0.0,"
"                               0.0,                        0.0, 0.0, 1.0);"
"    gl_Position = position * rotationMatrix;"
"    texCoordVarying = texCoord;"
"}";
  1. 片元着色器
const GLchar *shader_fsh = (const GLchar*)"varying highp vec2 texCoordVarying;"
"precision mediump float;"
"uniform sampler2D SamplerY;"
"uniform sampler2D SamplerUV;"
"uniform mat3 colorConversionMatrix;"
"void main()"
"{"
"    mediump vec3 yuv;"
"    lowp vec3 rgb;"
//   Subtract constants to map the video range start at 0
"    yuv.x = (texture2D(SamplerY, texCoordVarying).r - (16.0/255.0));"
"    yuv.yz = (texture2D(SamplerUV, texCoordVarying).rg - vec2(0.5, 0.5));"
"    rgb = colorConversionMatrix * yuv;"
"    gl_FragColor = vec4(rgb, 1);"
"}";

⚠️注意:先编译顶点着色器,再根据顶点着色器编译片元着色器

image.png

编译完成后,需要将着色器与program连接起来,涉及2个方法👇🏻

  • glAttachShader 附着
  • glBindAttribLocation 绑定

接下来就是链接👇🏻

- (BOOL)linkProgram:(GLuint)prog
{
    GLint status;
    glLinkProgram(prog);
    
#if defined(DEBUG)
    GLint logLength;
    glGetProgramiv(prog, GL_INFO_LOG_LENGTH, &logLength);
    if (logLength > 0) {
        GLchar *log = (GLchar *)malloc(logLength);
        glGetProgramInfoLog(prog, logLength, &logLength, log);
        NSLog(@"Program link log:\n%s", log);
        free(log);
    }
#endif
    
    glGetProgramiv(prog, GL_LINK_STATUS, &status);
    if (status == 0) {
        return NO;
    }
    
    return YES;
}

核心的就一句glLinkProgram(prog);,后面都是些错误状态status的打印。

最后就是uniforms的连接👇🏻

//uniform和"SamplerY"、"SamplerUV"、"preferredRotation"和"colorConversionMatrix"的连接
uniforms[UNIFORM_Y] = glGetUniformLocation(self.program, "SamplerY");
uniforms[UNIFORM_UV] = glGetUniformLocation(self.program, "SamplerUV");
uniforms[UNIFORM_ROTATION_ANGLE] = glGetUniformLocation(self.program, "preferredRotation");
uniforms[UNIFORM_COLOR_CONVERSION_MATRIX] = glGetUniformLocation(self.program, "colorConversionMatrix");

完整版👇🏻

- (BOOL)loadShaders {
    GLuint vertShader = 0, fragShader = 0;
    
    // Create the shader program.
    self.program = glCreateProgram();
    
    //编译 片元着色器和顶点着色器
    if(![self compileShaderString:&vertShader type:GL_VERTEX_SHADER shaderString:shader_vsh]) {
        NSLog(@"Failed to compile vertex shader");
        return NO;
    }
    
    if(![self compileShaderString:&fragShader type:GL_FRAGMENT_SHADER shaderString:shader_fsh]) {
        NSLog(@"Failed to compile fragment shader");
        return NO;
    }
    
    //附着 顶点着色器和片元着色器
    glAttachShader(self.program, vertShader);
    glAttachShader(self.program, fragShader);
    
    //绑定 ATTRIB_VERTEX 和 program的"position"属性 / ATTRIB_TEXCOORD 和program的"texCoord"属性
    glBindAttribLocation(self.program, ATTRIB_VERTEX, "position");
    glBindAttribLocation(self.program, ATTRIB_TEXCOORD, "texCoord");
    
    // Link the program.
    if (![self linkProgram:self.program]) {//link失败的处理
        NSLog(@"Failed to link program: %d", self.program);
        
        if (vertShader) {
            glDeleteShader(vertShader);
            vertShader = 0;
        }
        if (fragShader) {
            glDeleteShader(fragShader);
            fragShader = 0;
        }
        if (self.program) {
            glDeleteProgram(self.program);
            self.program = 0;
        }
        
        return NO;
    }
    
    //uniform和"SamplerY"、"SamplerUV"、"preferredRotation"和"colorConversionMatrix"的连接
    uniforms[UNIFORM_Y] = glGetUniformLocation(self.program, "SamplerY");
    uniforms[UNIFORM_UV] = glGetUniformLocation(self.program, "SamplerUV");
    uniforms[UNIFORM_ROTATION_ANGLE] = glGetUniformLocation(self.program, "preferredRotation");
    uniforms[UNIFORM_COLOR_CONVERSION_MATRIX] = glGetUniformLocation(self.program, "colorConversionMatrix");
    
    //此时uniform和self.program已经连接配置好了,那么之前的片元着色器和顶点着色器与program的连接就可以释放删除了
    // Release vertex and fragment shaders.
    if (vertShader) {
        glDetachShader(self.program, vertShader);
        glDeleteShader(vertShader);
    }
    if (fragShader) {
        glDetachShader(self.program, fragShader);
        glDeleteShader(fragShader);
    }
    
    return YES;
}

3.2 渲染显示数据

接下来就是渲染这块的处理,先看ViewController.m中的调用处代码👇🏻

- (void)videoDecodeCallback:(CVPixelBufferRef)imageBuffer {
    //显示
    if (imageBuffer) {
        _displayLayer.pixelBuffer = imageBuffer;
    }
}

在解码器的回调方法中,通过设置图层_displayLayerpixelBuffer属性来传输解码后的数据,所以属性pixelBuffer的set方法,就是渲染的入口

//渲染入口
- (void)setPixelBuffer:(CVPixelBufferRef)pb {
// ...
}

废话不多说,直接上代码👇🏻

- (void)setPixelBuffer:(CVPixelBufferRef)pb {
    //视频是一帧一帧的数据,不断往里填充,之前的缓存中已经显示过了,所以必须先清空
    if(_pixelBuffer) {
        CVPixelBufferRelease(_pixelBuffer);
    }

    //获得数据
    _pixelBuffer = CVPixelBufferRetain(pb);
    
    //获取视频帧的宽和高
    int frameWidth = (int)CVPixelBufferGetWidth(_pixelBuffer);
    int frameHeight = (int)CVPixelBufferGetHeight(_pixelBuffer);

    //显示数据
    [self displayPixelBuffer:_pixelBuffer width:frameWidth height:frameHeight];
}

3.2.1 显示前的准备

渲染显示的流程大致包括👇🏻

  1. 获取颜色转换矩阵
  2. 创建纹理
  3. 设置纹理的属性
  4. 顶点坐标的处理
  5. 纹理坐标的处理

其中,第1步是为第3步做准备。

关键函数
  • 获取颜色转换矩阵 👉🏻 CVBufferGetAttachment

image.png

参数释义:
参数1:像素缓存区
参数2:YUV -> RGB kCVImageBufferYCbCrMatrixKey
参数3:附加模式 一般传值NULL

  • 创建纹理缓冲区 👉🏻 CVOpenGLESTextureCacheCreate

image.png

参数释义:
参数1:分配器
参数2:缓冲区属性配置信息(字典类型),一般传NULL
参数3:上下文EAGLContext
参数4:创建纹理CVOpenGLESTexture对象所需要的配置信息(字典类型),一般传NULL
参数5:缓冲区输出保存对象的指针

  • 创建纹理 👉🏻 CVOpenGLESTextureCacheCreateTextureFromImage

image.png 参数释义:
参数1:分配器
参数2:纹理缓冲区
参数3:解码后的流数据CVPixelBufferRef
参数4:创建纹理对象CVOpenGLESTexture所需要的配置信息(字典类型),一般传NULL
参数5:纹理类型,当前只支持GL_TEXTURE_2D 或者 GL_RENDERBUFFER
参数6:颜色组件
参数7:纹理宽度
参数8:纹理高度
参数9:指定像素数据格式。例如GL_RGBA和GL_LUMINANCE
参数10:指定像素数据数据类型。例如GL_UNSIGNED_BYTE
参数11:指定要映射绑定的CVImageBuffer的索引值
参数12:新创建的纹理对象将被存储到该参数

3.2.2 显示的核心流程

核心函数介绍完毕后,剩下的就是显示的核心方法displayPixelBuffer:width:height:,它大致包含几部分流程👇🏻

部分1:特殊情况的优先处理
  • 上下文是否正常
if (!_context || ![EAGLContext setCurrentContext:_context]) {
    return;
}
  • 解码后的数据为NULL 👉🏻 直接返回
if(pixelBuffer == NULL) {
    NSLog(@"Pixel buffer is null");
    return;
}
部分2:获取颜色转换矩阵
    //获取像素缓存区的PlaneCount
    size_t planeCount = CVPixelBufferGetPlaneCount(pixelBuffer);
    //CVBufferGetAttachment获取颜色转换矩阵CFTypeRef
    CFTypeRef colorAttachments = CVBufferGetAttachment(pixelBuffer, kCVImageBufferYCbCrMatrixKey, NULL);
    
    //CFStringCompare比较颜色
    if (CFStringCompare(colorAttachments, kCVImageBufferYCbCrMatrix_ITU_R_601_4, 0) == kCFCompareEqualTo) {
        _preferredConversion = kColorConversion601;
    } else {
        _preferredConversion = kColorConversion709;
    }

其中,_preferredConversion是所需要的颜色标准,获取到颜色转换矩阵CFTypeRef后,再指定_preferredConversionkColorConversion601(标清)还是kColorConversion709(高清)👇🏻

// BT.601, which is the standard for SDTV.
static const GLfloat kColorConversion601[] = {
    1.1641.164, 1.164,
    0.0, -0.392, 2.017,
    1.596, -0.813,   0.0,
};

// BT.709, which is the standard for HDTV.
static const GLfloat kColorConversion709[] = {
    1.1641.164, 1.164,
    0.0, -0.213, 2.112,
    1.793, -0.533,   0.0,

};
部分3:创建纹理
CVReturn err;
CVOpenGLESTextureCacheRef _videoTextureCache;
//从上下文_context中,创建纹理缓冲区,输出到_videoTextureCache中,为创建纹理做准备
err = CVOpenGLESTextureCacheCreate(kCFAllocatorDefault, NULL, _context, NULL, &_videoTextureCache);
if (err != noErr) {
    NSLog(@"Error at CVOpenGLESTextureCacheCreate %d", err);
    return;
}

//激活纹理
glActiveTexture(GL_TEXTURE0);

接着创建Y纹理,即亮度纹理

    err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                       _videoTextureCache,
                                                       pixelBuffer,
                                                       NULL,
                                                       GL_TEXTURE_2D,
                                                       GL_RED_EXT,//颜色组件
                                                       frameWidth,
                                                       frameHeight,
                                                       GL_RED_EXT,
                                                       GL_UNSIGNED_BYTE,
                                                       0,
                                                       &_lumaTexture);
    if (err) {
        NSLog(@"Error at CVOpenGLESTextureCacheCreateTextureFromImage %d", err);
    }
    //绑定Y纹理
    glBindTexture(CVOpenGLESTextureGetTarget(_lumaTexture), CVOpenGLESTextureGetName(_lumaTexture));
    //设置纹理的属性👇🏻
    //1.放大/缩小的过滤
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
    glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
    //2.环绕方式 
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
    glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);

再创建UV纹理,即色度纹理 👇🏻

    if(planeCount == 2) {
        // UV-plane.
        glActiveTexture(GL_TEXTURE1);
        err = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
                                                           _videoTextureCache,
                                                           pixelBuffer,
                                                           NULL,
                                                           GL_TEXTURE_2D,
                                                           GL_RG_EXT,
                                                           frameWidth / 2,
                                                           frameHeight / 2,
                                                           GL_RG_EXT,
                                                           GL_UNSIGNED_BYTE,
                                                           1,
                                                           &_chromaTexture);
        if (err) {
            NSLog(@"Error at CVOpenGLESTextureCacheCreateTextureFromImage %d", err);
        }
        //绑定UV纹理
        glBindTexture(CVOpenGLESTextureGetTarget(_chromaTexture), CVOpenGLESTextureGetName(_chromaTexture));
        //配置UV纹理的属性
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
        glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
        glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
    }
部分4: 帧缓存区、着色器和uniforms的准备
    //绑定帧缓存区
    glBindFramebuffer(GL_FRAMEBUFFER, _frameBufferHandle);
    
    // Set the view port to the entire view.
    glViewport(0, 0, _backingWidth, _backingHeight);
    
    //清理屏幕
    glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
    glClear(GL_COLOR_BUFFER_BIT);
    
    // Use shader program.
    glUseProgram(self.program);
    
    //传递值
    //渲染角度
    glUniform1f(uniforms[UNIFORM_ROTATION_ANGLE], 0);
    //颜色转换矩阵
    glUniformMatrix3fv(uniforms[UNIFORM_COLOR_CONVERSION_MATRIX], 1, GL_FALSE, _preferredConversion);
部分5: 顶点坐标和纹理坐标的处理

首先是顶点坐标 👇🏻

    //根据视频的方向和纵横比,来设置最终显示的视频的frame
    CGRect viewBounds = self.bounds;
    CGSize contentSize = CGSizeMake(frameWidth, frameHeight);
    /**
     AVMakeRectWithAspectRatioInsideRect 👉🏻 计算纵横比
     参数1:size 纵横比
     参数2:填充的矩形rect
     */
    CGRect vertexSamplingRect = AVMakeRectWithAspectRatioInsideRect(contentSize, viewBounds);
    
    // 计算rect的坐标,用来绘制矩形rect
    CGSize normalizedSamplingSize = CGSizeMake(0.0, 0.0);
    CGSize cropScaleAmount = CGSizeMake(vertexSamplingRect.size.width/viewBounds.size.width,
                                        vertexSamplingRect.size.height/viewBounds.size.height);
    //规范化rect的四个角的坐标点,即将四个角坐标点计算成(-1,1)的范围区间内,因为OpenGL ES的坐标范围是(-1,1)
    if (cropScaleAmount.width > cropScaleAmount.height) {
        normalizedSamplingSize.width = 1.0;
        normalizedSamplingSize.height = cropScaleAmount.height/cropScaleAmount.width;
    }
    else {
        normalizedSamplingSize.width = cropScaleAmount.width/cropScaleAmount.height;
        normalizedSamplingSize.height = 1.0;;
    }
    
    //以下是把四个角的坐标换算,判断是在4个象限中的哪个象限
    //扩展:图像在平面中,根据水平X轴和垂直的Y轴,按照从左至右,从上至下的顺序,可划分成第一、第二、第三和第四象限
    GLfloat quadVertexData [] = {
        -1 * normalizedSamplingSize.width, -1 * normalizedSamplingSize.height,
        normalizedSamplingSize.width, -1 * normalizedSamplingSize.height,
        -1 * normalizedSamplingSize.width, normalizedSamplingSize.height,
        normalizedSamplingSize.width, normalizedSamplingSize.height,
    };
    
    //glVertexAttribPointer 👉🏻 将顶点坐标值传递到OpenGL ES里面
    glVertexAttribPointer(ATTRIB_VERTEX, 2, GL_FLOAT, 0, 0, quadVertexData);
    glEnableVertexAttribArray(ATTRIB_VERTEX);

接着是纹理坐标的处理👇🏻

    //纹理坐标是倒的,需要翻转
    CGRect textureSamplingRect = CGRectMake(0, 0, 1, 1);
    GLfloat quadTextureData[] =  {
        CGRectGetMinX(textureSamplingRect), CGRectGetMaxY(textureSamplingRect),
        CGRectGetMaxX(textureSamplingRect), CGRectGetMaxY(textureSamplingRect),
        CGRectGetMinX(textureSamplingRect), CGRectGetMinY(textureSamplingRect),
        CGRectGetMaxX(textureSamplingRect), CGRectGetMinY(textureSamplingRect)
    };
    //将纹理坐标值传递到OpenGL ES里面
    glVertexAttribPointer(ATTRIB_TEXCOORD, 2, GL_FLOAT, 0, 0, quadTextureData);
    glEnableVertexAttribArray(ATTRIB_TEXCOORD);
部分6:绘制显示数据并到屏幕
    //glDrawArrays 👉🏻 绘制数据
    glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
    //glBindRenderbuffer 👉🏻 从渲染缓存区里面取数据,显示到屏幕上
    glBindRenderbuffer(GL_RENDERBUFFER, _colorBufferHandle);
    [_context presentRenderbuffer:GL_RENDERBUFFER];

至此,我们完成了视频的显示

部分7:清理

因为我们在初始化方法中,设置了kEAGLDrawablePropertyRetainedBacking是YES 👉🏻 表示视频绘制完成后保留内容,所以需要cleanUpTextaures清空数据👇🏻

[self cleanUpTextures];

cleanUpTextures的实现就是清除亮度纹理色度纹理👇🏻

- (void) cleanUpTextures {
    if (_lumaTexture) {
        CFRelease(_lumaTexture);
        _lumaTexture = NULL;
    }

    if (_chromaTexture) {
        CFRelease(_chromaTexture);
        _chromaTexture = NULL;
    }
}

同时,由于我们的视频是一帧一帧的流数据,实时解析并显示的,所以每一帧的纹理缓存区也需要清空👇🏻

    CVOpenGLESTextureCacheFlush(_videoTextureCache, 0);
    if(_videoTextureCache) {
        CFRelease(_videoTextureCache);
    }

3.3 dealloc清理

当然,我们封装的图层AAPLEAGLLayer,也需要在dealloc中清理一些对象,包括_context 上下文,2个纹理,解码后的流数据_pixelBufferprogram👇🏻

- (void)dealloc {
    if (!_context || ![EAGLContext setCurrentContext:_context]) {
        return;
    }
    
    [self cleanUpTextures];
    
    if(_pixelBuffer) {
        CVPixelBufferRelease(_pixelBuffer);
    }
    
    if (self.program) {
        glDeleteProgram(self.program);
        self.program = 0;
    }
    if(_context) {
        //[_context release];
        _context = nil;
    }
}

总结

  • 解码工具类封装
    • 初始化
      • 工具类初始化 👉🏻 依赖配置类CCVideoConfig,创建解码队列回调队列
      • 解码器VTDecompressionSessionRef的初始化,需要以下参数👇🏻
        • 根据sps/pps设置解码的视频输出格式 👉🏻 CMVideoFormatDescriptionCreateFromH264ParameterSets
        • 解码器参数的配置,常用的包括摄像头的输出数据格式、视频源的分辨率零拷贝通道
        • 解码器的回调设置,回调是一个结构体VTDecompressionOutputCallbackRecord
    • 解码
      • 解码队列上异步进行
      • 取第5个字节(数据类型type),换算10进制,判断帧类型
      • sps/pps 👉🏻 只保存不解码,给初始化解码器做准备
      • 关键帧和其他非关键帧 👉🏻 解码前需先判断解码器初始化是否完成
      • 解码步骤
        1. 创建CMBlockBuffer
        2. 创建CMSampleBuffer
        3. VTDecompressionSessionDecodeFrame异步解码CMSampleBuffer,且解码后的数据存储在CVPixelBufferRef
        4. 解码完成后释放CMBlockBufferCMSampleBuffer
    • 解码的回调
      • 获取解码后的数据sourceFrameRefCon -> CVPixelBufferRef
      • 获取self 👉🏻 桥接转换decompressionOutputRefCon
      • 回调队列上异步进行delegate输出
      • 最后释放数据CVImageBufferRef
  • 渲染显示
    • 过程:视频的渲染-->纹理的渲染-->片元着色器填充-->width*height正方形(渲染2个纹理)
    • OpenGL ES跨平台核心 👉🏻 OpenGL ES负责核心的渲染动作,而编译器才决定显示
    • 图层类AAPLEAGLLayer的封装
      • 继承关系:继承CAEAGLLayer(iOS原生库QuartzCore里的,是iOS macOS提供的一个专门用来渲染OpenGL ES的图层),而CAEAGLLayer继承CALayer
      • 初始化
        • 图层类的初始化
          • scale、透明度、frame、上下文context、颜色转换类型kColorConversion709
          • kEAGLDrawablePropertyRetainedBacking视频绘制完成后是否需要保留其内容
        • OpenGL的初始化
          • 设置缓存区
            • 配置顶点信息
            • 配置纹理坐标信息(x, y)
            • 创建buffer缓存区:帧缓存区 和 渲染缓存区
          • 加载着色器Shaders
            • 片元着色器顶点着色器的处理,顺序:先顶点再片元
            • glCreateShader创建Shader + glShaderSource绑定 + glCompileShader编译
            • 链接 glLinkProgram
            • uniforms与program 的连接
            • 释放shader和program的连接
          • glUseProgram使用program
          • glUniform1i设置Uniform相关的参数:Y纹理UV纹理渲染角度颜色变换矩阵
      • 渲染显示数据
        • 实时渲染,需清空之前的CVPixelBufferRef数据
        • 获取视频帧的宽和高
        • 渲染显示流程
          • 获取颜色转换矩阵 👉🏻 CVBufferGetAttachment
          • 创建纹理
            • 创建纹理缓冲区 👉🏻 CVOpenGLESTextureCacheCreate
            • 激活纹理 👉🏻 glActiveTexture
            • 创建纹理 👉🏻 CVOpenGLESTextureCacheCreateTextureFromImage
            • 绑定纹理 👉🏻 glBindTexture
            • 配置纹理的属性 👉🏻 glTexParameteri
          • 帧缓存区、着色器和uniforms的准备
            • 绑定帧缓存区glBindFramebuffer
            • 清理屏幕
            • 使用着色器glUseProgram
            • uniforms的准备:渲染角度颜色转换矩阵
          • 顶点坐标和纹理坐标的处理
            • 顶点坐标:根据视频帧的宽高绘制矩形,并将矩形的四个角坐标值规范到四个象限之中
              • 计算纵横比AVMakeRectWithAspectRatioInsideRect
              • 计算rect的坐标
              • 规范化rect的四个角的坐标点,OpenGL ES的坐标范围是(-1,1)
              • 坐标换算,对应到4个象限之中
              • glVertexAttribPointer glEnableVertexAttribArray 将纹理坐标值传递到OpenGL ES里面
            • 纹理坐标
              • 纹理坐标是倒的,需要翻转
              • glVertexAttribPointer glEnableVertexAttribArray 将纹理坐标值传递到OpenGL ES里面
          • 绘制显示数据并到屏幕
            • 绘制数据 👉🏻 glDrawArrays
            • 从渲染缓存区里面取数据 👉🏻 glBindRenderbuffer
            • 显示到屏幕上 👉🏻 [_context presentRenderbuffer:xxx]
          • 最后的清理工作
            • 释放亮度纹理色度纹理 👉🏻 CFRelease

            • 清空纹理缓存区 👉🏻 CVOpenGLESTextureCacheFlush + CFRelease