iOS 使用 OpenGLES 渲染相机预览画面

前言

上一篇有提到 使用 AVFoundation 采集相机画面,并渲染到苹果内置的 AVCaptureVideoPreviewLayer 图层上,代码很简单,但使用上有很大局限性。AVCaptureSession 采集到的原始视频帧直接给到了 AVCaptureVideoPreviewLayer 用于渲染,我们没办法在中间环节处理视频帧数据,美颜滤镜也就没办法实现,所以我们需要借助 OpenGLES 自己实现画面渲染。

苹果的 GLKit 框架对 OpenGLES 的部分接口调用做了封装,使用起来非常方便。我们的需求可以直接使用 GLKView 实现,开发的代码量是比较少的,但为了熟悉 OpenGLES 的接口调用和管线渲染流程,我们还是使用最原始的方法,基于 CAEAGLLayer,自己来实现着色器代码。

代码实现

准备工作

使用 CAEAGLLayer

首先在项目中创建一个继承 UIView 的 XPGLKView,如果需要在图层上自定义 OpenGL 渲染,需要将 UIView 的 layerClass 设置为 CAEAGLLayer:

1
2
3
4
5
@implementation XPGLKView

+(Class)layerClass {
return [CAEAGLLayer class];
}

顶点数据计算

我们需要计算两类坐标,一类是 OpenGL 顶点坐标,一类是纹理贴图的坐标,先定义一个结构体类型,包含两类坐标:

1
2
3
4
typedef struct {
GLKVector2 positionCoordinates;
GLKVector2 textureCoordinates;
} VerticesCoordinates;

我们的场景只需要 4 个顶点,使用 glDrawArrays(GL_TRIANGLE_STRIP, 0, 4); 即可绘制两个三角形,按照顶点坐标和纹理坐标一一对应的关系,可以得到一个初步的顶点数据:

1
2
3
4
5
6
VerticesCoordinates vertices[] = {
{{-1.0f, -1.0f}, {0.0f, 0.0f}}, // 左下
{{ 1.0f, -1.0f}, {1.0f, 0.0f}}, // 右下
{{-1.0f, 1.0f}, {0.0f, 1.0f}}, // 左上
{{ 1.0f, 1.0f}, {1.0f, 1.0f}} // 右上
};

上面看着没什么问题,但实际渲染发现图像上下颠倒了,原因是使用 CoreVideo 框架读取 Pixel Buffer 数据时是按照屏幕坐标原点(左上角)开始的,而纹理坐标正好相反,所以我们需要翻转一下纹理坐标的 Y 轴,得到最终的顶点数据:

1
2
3
4
5
6
VerticesCoordinates vertices[] = {
{{-1.0f, -1.0f}, {0.0f, 1.0f}}, // 左下
{{ 1.0f, -1.0f}, {1.0f, 1.0f}}, // 右下
{{-1.0f, 1.0f}, {0.0f, 0.0f}}, // 左上
{{ 1.0f, 1.0f}, {1.0f, 0.0f}} // 右上
};

着色器程序

为了代码整洁,我们封装一个着色器程序,包含着色器的初始化、编译和链接功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
// 初始化方法接受顶点着色器和片段着色器的代码字符串,创建着色器程序后,调用 compileShader 编译着色器,最后将着色器绑定在当前着色器程序上
- (instancetype)initWithVertexShaderString:(NSString *)vShaderString fragmentShaderString:(NSString *)fShaderString {
self = [super init];
if (self) {
_attributes = [NSMutableArray array];
_ID = glCreateProgram();

if (![self compileShader:&_vertexShader type:GL_VERTEX_SHADER string:vShaderString]) {
NSLog(@"Failed to compile vertex shader");
}

if (![self compileShader:&_fragmentShader type:GL_FRAGMENT_SHADER string:fShaderString]) {
NSLog(@"Failed to compile fragment shader");
}

glAttachShader(_ID, _vertexShader);
glAttachShader(_ID, _fragmentShader);
}
return self;
}

// 释放
- (void)dealloc {
if (_vertexShader) {
glDeleteShader(_vertexShader);
}
if (_fragmentShader) {
glDeleteShader(_fragmentShader);
}
if (_ID) {
glDeleteProgram(_ID);
}
}

// 创建、编译着色器
- (BOOL)compileShader:(GLuint *)shader type:(GLenum)type string:(NSString *)shaderString {
GLint status;
const GLchar *source = (GLchar *)[shaderString UTF8String];
if (!source) {
NSLog(@"Failed to load shader string");
return NO;
}

// 创建着色器
*shader = glCreateShader(type);
// 绑定着色器代码
glShaderSource(*shader, 1, &source, NULL);
// 编译着色器
glCompileShader(*shader);

// 检查着色器编译状态
glGetShaderiv(*shader, GL_COMPILE_STATUS, &status);
if (status != GL_TRUE) {
GLint logLength;
glGetShaderiv(*shader, GL_INFO_LOG_LENGTH, &logLength);
if (logLength) {
GLchar *log = (GLchar *)malloc(logLength);
glGetShaderInfoLog(*shader, logLength, &logLength, log);
if (shader == &_vertexShader) {
self.vertexShaderLog = [NSString stringWithFormat:@"%s", log];
} else {
self.fragmentShaderLog = [NSString stringWithFormat:@"%s", log];
}
free(log);
}
}
return status == GL_TRUE;
}

// 链接着色器
- (BOOL)link {
GLint status;
glLinkProgram(_ID);
glGetProgramiv(_ID, GL_LINK_STATUS, &status);
if (status == GL_FALSE) {
return NO;
}

if (_vertexShader) {
glDeleteShader(_vertexShader);
_vertexShader = 0;
}
if (_fragmentShader) {
glDeleteShader(_fragmentShader);
_fragmentShader = 0;
}
return YES;
}

// 使用着色器程序
- (void)use {
glUseProgram(_ID);
}

// 给顶点着色器属性绑定 location
- (void)addAttribute:(NSString *)attributeName {
if (![_attributes containsObject:attributeName]) {
[_attributes addObject:attributeName];
glBindAttribLocation(_ID, (GLuint)[_attributes indexOfObject:attributeName], [attributeName UTF8String]);
}
}

// 获取顶点着色器属性 location
- (GLuint)getAttributeLocation:(NSString *)attributeName {
return (GLuint)[_attributes indexOfObject:attributeName];
}

// 获取 uniform 属性 localtion
- (GLuint)getUniformLocation:(NSString *)uniformName {
return glGetUniformLocation(_ID, [uniformName UTF8String]);
}

// 打印错误信息
- (void)showError {
NSString *progLog = [self programLog];
NSLog(@"Program link log: %@", progLog);
NSString *fragLog = [self fragmentShaderLog];
NSLog(@"Fragment shader compile log: %@", fragLog);
NSString *vertLog = [self vertexShaderLog];
NSLog(@"Vertex shader compile log: %@", vertLog);
}

有了着色器程序,再来看下顶点着色器和片段着色器的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// 顶点着色器
static NSString *XP_GLK_VSH = @" \
attribute vec4 position; \
attribute vec4 inputTextureCoordinate; \
varying vec2 textureCoordinate; \
\
void main() \
{ \
gl_Position = position; \
textureCoordinate = inputTextureCoordinate.xy; \
} \
";

// 顶点着色器有两个 attribute 的属性,position 是从顶点缓冲区获取的顶点坐标
// inputTextureCoordinate 则是纹理坐标,这两个属性的类型是 vec4,是一个四维的坐标
// 而我们创建的顶点、纹理坐标是二维的,opengl 会使用默认自动转成 vec4 类型
// textureCoordinate 是顶点着色器传递给片段着色器的属性,片段着色器使用 textureCoordinate 计算纹理颜色

// ************************************************************************

// 片段着色器
static NSString *XP_GLK_FSH = @" \
varying highp vec2 textureCoordinate; \
uniform sampler2D inputImageTexture; \
\
void main() \
{ \
gl_FragColor = texture2D(inputImageTexture, textureCoordinate); \
} \
";

// textureCoordinate 是从顶点着色器传过来的顶点坐标,inputImageTexture 是纹理采样器
// texture2D()方法计算纹理在该点的颜色值,输出给 gl_FragColor
// 纹理采样器需要在使用前先绑定到纹理单元,稍后代码会有提到

有了顶点数据和着色器程序,接下来看一下 XPGLKView 的初始化代码。

初始化

初始化方法中设置一些变量的默认值,主要还是配置 OpenGL 环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
- (void)commonInit {
// 标志位用于控制后台停止渲染
_needStopDisplay = NO;
// 填充模式
_fillMode = XPVideoFillModeAspectFill;
// 递归锁 用来控制 openGL 的 API
_renderLock = [[NSRecursiveLock alloc] init];

// add observer
[self addObservers];

// layer
// 首先是拿到 UIView 上的 CAEAGLLayer 图层做一些设置,opaque 属性设置为 YES 可以提升渲染效率
// contentsScale 比例因子设置为屏幕的比例因子,是为了适配 Retina 这类高分辨率的屏幕
// drawableProperties 设置的两个 Key & Value,kEAGLDrawablePropertyRetainedBacking 设置为 NO 表示当前帧绘制后就清空其内容
// kEAGLDrawablePropertyColorFormat 设置为 kEAGLColorFormatRGBA8 设置 renderBuffer 按 32位存储
CAEAGLLayer *layer = (CAEAGLLayer *)self.layer;
layer.opaque = YES;
layer.contentsScale = [[UIScreen mainScreen] scale];
layer.drawableProperties = [NSDictionary dictionaryWithObjectsAndKeys:[NSNumber numberWithBool:NO], kEAGLDrawablePropertyRetainedBacking, kEAGLColorFormatRGBA8, kEAGLDrawablePropertyColorFormat, nil];

// EAGLContext
// 创建 OpenGL 上下文,如果不支持 OpenGLES 3.0 则使用 2.0 版本,创建好后将上下文绑定到当前线程。
if (!_context) {
_context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
if (!_context) {
_context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES2];
}
}
if (!_context || ![EAGLContext setCurrentContext:_context]) {
NSLog(@"failed to setup EAGLContext");
}

// clear color - black
// 默认填充颜色 - 黑色
_backgroundColor = GLKVector4Make(0.0f, 0.0f, 0.0f, 1.0f);

// texture cache
// 创建纹理数据缓冲区,CVOpenGLESTextureRef 和 CVOpenGLESTextureCacheRef 来自 CoreVideo 框架
// 此例中用于将相机回调的 Pixel Buffer 转换成 OpenGL 的纹理缓存
CVReturn ret = CVOpenGLESTextureCacheCreate(kCFAllocatorDefault, NULL, _context, NULL, &_textureCache);
if (ret != kCVReturnSuccess) {
NSLog(@"CVOpenGLESTextureCacheCreate: %d", ret);
}

// program
// 创建着色器程序,编译链接顶点着色器和片段着色器,并给顶端着色器绑定两个属性,顶点坐标和纹理坐标
_program = [[XPGLKProgram alloc] initWithVertexShaderString:XP_GLK_VSH fragmentShaderString:XP_GLK_FSH];
[_program addAttribute:@"position"];
[_program addAttribute:@"inputTextureCoordinate"];
if (![_program link]) {
[_program showError];
NSLog(@"Filter shader link failed");
_program = nil;
}

// create FBO
// 创建帧缓存
glGenFramebuffers(1, &_frameBuffer);
glBindFramebuffer(GL_FRAMEBUFFER, _frameBuffer);

// 创建颜色渲染缓存
glGenRenderbuffers(1, &_renderBuffer);
glBindRenderbuffer(GL_RENDERBUFFER, _renderBuffer);

// 借助 layer 给 renderBuffer 分配空间(像素点个数、每个像素点占多少位)
[_context renderbufferStorage:GL_RENDERBUFFER fromDrawable:(CAEAGLLayer *)self.layer];

// 获取初始化后的 renderBuffer 宽高
GLint backingWidth, backingHeight;
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_WIDTH, &backingWidth);
glGetRenderbufferParameteriv(GL_RENDERBUFFER, GL_RENDERBUFFER_HEIGHT, &backingHeight);

// 如果是 0,直接销毁返回
if (backingWidth == 0 || backingHeight == 0) {
[self destroyFrameBuffer];
return;
}

// 记录下来,用于调整视口大小
_viewportSize.width = (CGFloat)backingWidth;
_viewportSize.height = (CGFloat)backingHeight;

// 将 renderBuffer 绑定到帧缓存的颜色缓冲区
glFramebufferRenderbuffer(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_RENDERBUFFER, _renderBuffer);
// 检查帧缓存状态
GLuint framebufferStatus = glCheckFramebufferStatus(GL_FRAMEBUFFER);
if (framebufferStatus != GL_FRAMEBUFFER_COMPLETE) {
NSLog(@"Fail setup GL framebuffer %d:%d", backingWidth, backingHeight);
} else {
NSLog(@"Success setup GL framebuffer %d:%d", backingWidth, backingHeight);
}

// create VBO
// 创建顶点数据缓存
glGenBuffers(1, &_vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(_vertices), _vertices, GL_DYNAMIC_DRAW);

// attributes、uniforms
// 获取顶点着色器定义的属性的 location
_positionAttribute = [_program getAttributeLocation:@"position"];
_textureCoordinateAttribute = [_program getAttributeLocation:@"inputTextureCoordinate"];

// 获取片段着色器定义的纹理采样器的 location
_inputTextureUniform = [_program getUniformLocation:@"inputImageTexture"];

// 打开顶点着色器属性
glEnableVertexAttribArray(_positionAttribute);
glEnableVertexAttribArray(_textureCoordinateAttribute);
}

首先通过 layer 配置帧缓冲区的一些属性,随后初始化并绑定上下文,创建我们封装过的着色器程序,创建帧缓存、顶点缓存,最后将顶点着色器属性打开。

渲染

初始化之后,就可以准备渲染画面了,渲染方法触发的时机是 AVCaptureSession 的相机数据帧回调,我们在回调里切换渲染线程,调用 displayPixelBuffer 方法:

1
2
3
4
5
6
7
#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
CVPixelBufferRef originPixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
XPDispatchSync(self.renderQueue, cameraSourceRenderQueueKey, ^{
[self.previewView displayPixelBuffer:originPixelBuffer];
});
}

渲染的方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
- (void)displayPixelBuffer:(CVPixelBufferRef)pixelBuffer {
// 避免在当前上下文使用多线程调用 opengl 接口 这里用了递归锁
[_renderLock lock];

// check needStopDisplay
// 后台不渲染
if (_needStopDisplay) {
[_renderLock unlock];
return;
}

// checkout eagl context
// 校验上下文
if ([EAGLContext currentContext] != _context) {
[EAGLContext setCurrentContext:_context];
}

// use program
// 使用着色器程序
[_program use];

// bind frame buffer
// 绑定帧缓冲 根据颜色渲染缓冲的 buffer 大小更新视口
glBindFramebuffer(GL_FRAMEBUFFER, _frameBuffer);
glViewport(0, 0, (GLint)_viewportSize.width, (GLint)_viewportSize.height);

// clean cache
// 刷新深度缓冲、颜色缓冲
glClearColor(_backgroundColor.r, _backgroundColor.g, _backgroundColor.b, _backgroundColor.a);
glClear(GL_COLOR_BUFFER_BIT | GL_DEPTH_BUFFER_BIT);

// clean texture and texture cache
// 清理纹理缓冲
if (_texture) {
CFRelease(_texture);
_texture = NULL;
}
CVOpenGLESTextureCacheFlush(_textureCache, 0);

// create a CVOpenGLESTexture from the CVImageBuffer
// pixel buffer 转换成纹理数据
size_t frameWidth = CVPixelBufferGetWidth(pixelBuffer);
size_t frameHeight = CVPixelBufferGetHeight(pixelBuffer);
CVPixelBufferLockBaseAddress(pixelBuffer, 0);
// 相当于 glTexImage2D()
CVReturn ret = CVOpenGLESTextureCacheCreateTextureFromImage(kCFAllocatorDefault,
_textureCache,
pixelBuffer,
NULL,
GL_TEXTURE_2D,
GL_RGBA, // 颜色分量数量
(GLsizei)frameWidth,
(GLsizei)frameHeight,
GL_BGRA, // 像素数据格式
GL_UNSIGNED_BYTE,
0,
&_texture);
if (!_texture || ret != kCVReturnSuccess) {
NSLog(@"error: Mapping texture:%d", ret);
}
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);

// handle texture
// 激活纹理单元(一般 GL_TEXTURE0 这个纹理单元默认会被激活,不需要手动调用)
glActiveTexture(GL_TEXTURE0);
// 将刚生成的纹理到纹理缓冲区
glBindTexture(CVOpenGLESTextureGetTarget(_texture), CVOpenGLESTextureGetName(_texture));
// 配置纹理参数
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE);
glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE);
// 将纹理单元 0 绑定到片段着色器的 inputImageTexture 纹理采样器
glUniform1i(_inputTextureUniform, 0);

// update vertices
// 通过比对当前帧的宽高和前一帧的宽高,决定是否需要更新顶点坐标
[self updateInputImageSize:CGSizeMake(frameWidth, frameHeight)];

// bind vertex buffer and handle vertex shader attribute pointer
// 绑定顶点缓冲
glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer);
// 告知顶点着色器如何解析顶点数据,即先解析两个 float 给 position 属性,再解析两个 float 给inputTextureCoordinate 属性。
glVertexAttribPointer(_positionAttribute, 2, GL_FLOAT, GL_FALSE, sizeof(VerticesCoordinates), (void *)offsetof(VerticesCoordinates, positionCoordinates));
glVertexAttribPointer(_textureCoordinateAttribute, 2, GL_FLOAT, GL_FALSE, sizeof(VerticesCoordinates), (void *)offsetof(VerticesCoordinates, textureCoordinates));

// draw two triangle with 4 vertices
// 根据给的 4 个顶点绘制两个三角形
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);

// 绑定 renderBuffer 并提交给 Core Animation
glBindRenderbuffer(GL_RENDERBUFFER, _renderBuffer);
[_context presentRenderbuffer:GL_RENDERBUFFER];

[_renderLock unlock];
}

适配填充模式

按目前的顶点和纹理坐标,渲染内容默认会铺满整个图层,如果相机回调的 Pixel Buffer 宽高与图层宽高不一致,会产生拉伸的现象,因此需要适配不同的填充模式,我们定义了三种填充模式:

1
2
3
4
5
typedef NS_ENUM(NSUInteger, XPVideoFillMode) {
XPVideoFillModeStretch, // 拉伸以填满屏幕
XPVideoFillModeAspectFit, // 保持宽高比,长边铺满屏幕,短边填充黑边
XPVideoFillModeAspectFill,// 保持宽高比,短边铺满屏幕,长边裁剪
};

每种填充模式的顶点坐标计算方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
- (void)recalculateVerticesCoordinates {
// 当前图层尺寸
CGSize currentViewSize = _currentBoundsSize;
CGRect currentViewBounds = CGRectMake(0, 0, currentViewSize.width, currentViewSize.height);
// 计算视频帧以 AspectRatio 方式填充在当前图层时,视频帧的 frame
CGRect insetRect = AVMakeRectWithAspectRatioInsideRect(_inputImageSize, currentViewBounds);

CGFloat heightScaling, widthScaling;
switch (_fillMode) {
case XPVideoFillModeStretch:
widthScaling = 1.0f;
heightScaling = 1.0f;
break;

case XPVideoFillModeAspectFit:
widthScaling = insetRect.size.width / currentViewSize.width;
heightScaling = insetRect.size.height / currentViewSize.height;
break;

case XPVideoFillModeAspectFill: {
widthScaling = currentViewSize.height / insetRect.size.height;
heightScaling = currentViewSize.width / insetRect.size.width;
}
break;

default:
break;
}

// 顶点坐标
_vertices[0].positionCoordinates = GLKVector2Make(-widthScaling, -heightScaling);
_vertices[1].positionCoordinates = GLKVector2Make(widthScaling, -heightScaling);
_vertices[2].positionCoordinates = GLKVector2Make(-widthScaling, heightScaling);
_vertices[3].positionCoordinates = GLKVector2Make(widthScaling, heightScaling);

// 纹理坐标
_vertices[0].textureCoordinates = GLKVector2Make(0.0f, 1.0f);
_vertices[1].textureCoordinates = GLKVector2Make(1.0f, 1.0f);
_vertices[2].textureCoordinates = GLKVector2Make(0.0f, 0.0f);
_vertices[3].textureCoordinates = GLKVector2Make(1.0f, 0.0f);
}

在渲染过程中,将顶点数据更新同步到 GPU:

1
glBufferSubData(GL_ARRAY_BUFFER, 0, sizeof(_vertices), _vertices);

后记

相机画面的自定义渲染没有用到 OpenGL 的高级功能,主要是对 OpenGL 对象、纹理、管线概念的理解,和 Core Animation、Core Video 的交互。下面贴一些文档可以用来参考:

1.OpenGL 中文手册 https://learnopengl-cn.github.io/

2.iOS 核心动画高级技巧 https://zsisme.gitbooks.io/ios-/content/index.html

3.OpenGLES 苹果官网文档 https://developer.apple.com/library/archive/documentation/3DDrawing/Conceptual/OpenGLES_ProgrammingGuide/Introduction/Introduction.html#//apple_ref/doc/uid/TP40008793-CH1-SW1

4.《OpenGL ES应用开发实践 指南 iOS卷》