修订:2018.1.20
本系列文档除本地窗口相关代码,其余部分可移植到Android上运行。环境:Xcode 7、iOS 9,设备为iPhone 6p、iPad Air 2。
本文档的任务是使用OpenGL ES 3接口,实现一个简单的读取经GPU处理的数据的程序,描述Transform Feedback的使用,方便后续学习粒子效果、图像处理等新内容。简洁起见,后续将OpenGL缩写为GL,OpenGL ES缩写为ES。关于Transform Feedback的使用,PowerVR官方出了一个demo模拟猫的活动Soft Kitty。
使用Transform Feedback时,OpenGL的program允许只使用vertex shader,没有fragment shader也可正常工作。然而,ES 3.0规定program必须搭配一对vertex shader和fragment shader,哪怕fragment shader输出无意义的颜色,否则链接阶段异常。
Transform Feedback的一个优势是把顶点着色器处理后的数据写回顶点缓冲区对象(Vertex Buffer Objects, VBOs),避免GPU、CPU之间往返拷贝数据,节省时间。由于iOS使用统一内存模型,GPU、CPU数据实际都在存储在主存,而Android不一定,如Nexus 6P,映射GPU地址到CPU,经我们团队测试,大约消耗20ms。
1、使用Transform Feedback并读取计算结果的流程
iOS、Android都遵循如下流程,不同的是E(A)GL与本地窗口(比如,UIKit)桥接的配置。
- 配置EGL上下文
- 提供用于计算的顶点着色器
- 配置program
- 在链接program前配置transform feedback输出属性名
- 链接program
- 配置GPU的输入数据缓冲区并上传数据至GPU
- 配置GPU的输出数据缓冲区并与transform feedback绑定
- 禁用光栅化等渲染管线后续操作
- 进入transform feedback模式
- 绘图调用
- 结束transform feedback模式
- 同步GPU
- 映射GPU内存
- 读取GPU计算结果
- 解除映射
操作1~8可放在初始化函数中,后续步骤开始渲染操作。经测试,在iOS上使用Transform Feedback必须配合GLKViewController。初始化过程使用的上下文信息,在栈或堆中声明并不影响操作结果。若有更复杂操作,如配合GPUImage使用,则应妥善管理EAGLContext。
2、示例实现
1、配置EGL上下文。参考我另一个文档OpenGL ES 3 编程 1:"Hello world"。简单起见,在此创建Game类型项目,在GLKViewController
子类中编写后续代码。继承GLKViewController
需正确配置GLKView,可能遇到的情况已在3.1节GLKit不使用GLView无效描述。
2、提供用于计算的顶点着色器。由于只做计算,不显示画面,这个Vertex Shader是CPU计算代码的GLSL实现版,也是计算真正执行的地方。
#version 300 es
layout(location = 0) in float inValue;
out float outValue;
void main()
{
outValue = sqrt(inValue);
}
Fragment Shader是空操作。
#version 300 es
void main()
{
}
我们使用GPU是想利用它的并行特性,那么,就iPad Air 2而言,有多少个统一并行计算单元呢?这个无法用OpenGL ES接口查询,需查找相应的芯片手册,下面这段查询代码输出无效,在此作只示例。
#import
//////////////////////////////////////////////////////
printf("%s\n", glGetString(GL_VERSION));
GLint vertexUnits;
glGetIntegerv(GL_MAX_VERTEX_UNITS_OES, &vertexUnits);
执行结果:
OpenGL ES-CM 1.1 Apple A8X GPU - 77.14
vertex units = 4
3、配置program。先不链接program。
GLuint vertShader, fragShader;
NSString *vertShaderPathname, *fragShaderPathname;
// Create shader program.
_program = glCreateProgram();
// Create and compile vertex shader.
vertShaderPathname = [[NSBundle mainBundle] pathForResource:@"Shader" ofType:@"vsh"];
if (![self compileShader:&vertShader type:GL_VERTEX_SHADER file:vertShaderPathname]) {
NSLog(@"Failed to compile vertex shader");
return NO;
}
// Create and compile fragment shader.
fragShaderPathname = [[NSBundle mainBundle] pathForResource:@"Shader" ofType:@"fsh"];
if (![self compileShader:&fragShader type:GL_FRAGMENT_SHADER file:fragShaderPathname]) {
NSLog(@"Failed to compile fragment shader");
return NO;
}
// Attach vertex shader to program.
glAttachShader(_program, vertShader);
// Attach fragment shader to program.
glAttachShader(_program, fragShader);
4、在链接program前配置transform feedback输出属性名。
仔细观察,本文使用的Vertex Shader与正常绘图所用的着色器略有区别:没输出顶点坐标给Fragment Shader使用。因此,需要glTransformFeedbackVaryings告诉ES欲捕获到输出缓冲区的变量信息。
GLchar *varyings[] = {"outValue"};
glTransformFeedbackVaryings(_program, sizeof(varyings) / sizeof(varyings[0]), varyings, GL_INTERLEAVED_ATTRIBS);
glTransformFeedbackVaryings需要输出变量的数量及名称,在varyings数组指定Vertex Shader将输出的变量名。
- GL_INTERLEAVED_ATTRIBS指定输出属性数值交错写入一个缓冲区。交错数据需要指定读写跨距(stride)。
- GL_SEPARATE_ATTRIBS为输出属性指定多个目标缓冲区,一对一写入或不同偏移写入到一个缓冲区。
5、链接program。链接操作包含检查链接状态(glLinkProgram),查找编译错误,在调试模式下还可验证当前ES状态是否可执行program中的程序(glValidateProgram),即找出其中运行时错误,根据校验情况,输出错误信息。glValidateProgram操作消耗资源较多,Release模式下通常不调用此函数。示例如下。
// 1、检查链接状态
glLinkProgram(_program);
GLint linkStatus = GL_FALSE;
glGetProgramiv(_program, GL_LINK_STATUS, &linkStatus);
if (linkStatus == GL_FALSE) {
GLint logLength = 0;
glGetProgramiv(_program, GL_INFO_LOG_LENGTH, &logLength);
if (logLength > 0) {
GLchar *logBuffer = calloc(1, logLength);
glGetProgramInfoLog(_program, logLength, NULL, logBuffer);
printf("%s", logBuffer);
free(logBuffer);
}
}
// 2、验证Shader是否可执行
glValidateProgram(_program);
glGetProgramiv(_program, GL_INFO_LOG_LENGTH, &logLength);
if (logLength > 0) {
GLchar *log = (GLchar *)malloc(logLength);
glGetProgramInfoLog(_program, logLength, &logLength, log);
NSLog(@"Program validate log:\n%s", log);
free(log);
}
glGetProgramiv(_program, GL_VALIDATE_STATUS, &status);
if (status == 0) {
return NO;
}
return YES;
6、配置GPU的输入数据缓冲区。这里需要注意,若直接上传数据,则不能映射上传缓冲区到主存去查看上传的数据。用缓冲区(Vertex Buffer Object)则正常。
使用VBO前,可以配置VAO,不配置也不影响运行结果。
GLuint vao;
glGenVertexArrays(1, &vao);
glBindVertexArray(vao);
上传数据方式A:直接上传数据至GPU的实现,yourData
为数组。
GLfloat yourData[] = {2, 3, 4, 5, 6};
glEnableVertexAttribArray(0); // layout(location = 0)指定了索引
glVertexAttribPointer(0, 1, GL_FLOAT, GL_FALSE, 0, yourData);
对于动态分配的内存,正常绘制没问题,但是,在此却得不到正确的运行结果。
GLfloat *data;
data = malloc(sizeof(GLfloat) * 5);
for (int i = 0; i < 5; ++i) {
data[i] = i + 2;
}
glEnableVertexAttribArray(0); // layout(location = 0)指定了索引
glVertexAttribPointer(0, 1, GL_FLOAT, GL_FALSE, 0, yourData);
// 配合glDrawArrays(GL_POINTS, 0, 5);指定了绘制元素数量,解决了动态分配内存无长度信息。
// 然而,Transform Feedback情况无效。
上传数据方式B:用Vertex Buffer Object。
GLfloat data[] = { 2, 3, 4, 5, 6 };
glGenBuffers(1, &_vertexBuffer);
glBindBuffer(GL_ARRAY_BUFFER, _vertexBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(data), data, GL_STATIC_DRAW);
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 1, GL_FLOAT, GL_FALSE, 0, NULL);
尽管多数OpenGL驱动可智能分析glBufferData的内存使用并使用恰当的管理方式,但是,WWDC一个演讲中,苹果的OpenGL ES驱动开发工程师建议我们按数据的使用方式,传递适合的内存管理参数提示值给系统。因此,在此场合,数据只作一次计算,故传递GL_STATIC_DRAW。
glEnableVertexAttribArray(0);
指定的索引若在shader中没编写,可通过GLint inputAttribIndex = glGetAttribLocation(program, "inValue");
方式获取。
7、配置GPU的输出数据缓冲区并与transform feedback绑定
glGenBuffers(1, &_gpuOutputBuffer);
glBindBuffer(GL_ARRAY_BUFFER, _gpuOutputBuffer);
glBufferData(GL_ARRAY_BUFFER, sizeof(data), NULL, GL_STREAM_DRAW);
glBindBuffer(GL_ARRAY_BUFFER, 0);
glBindBufferBase(GL_TRANSFORM_FEEDBACK_BUFFER, 0, _gpuOutputBuffer);
输出缓冲区调用glBufferData
时不指定数据源(传递NULL
),这只是分配装载GPU计算结果需要的内存,大小视输出结果而定,以字节为单位。这里的使用和Pixel Buffer Object一样。glBindBufferBase完成Transform Feedback输出与数据缓冲区的实际绑定。
- 参数
GL_TRANSFORM_FEEDBACK_BUFFER
:指定使用Transform Feedback缓冲区。 - 参数
0
:表示使用第1个输出属性,本例Vertex Shader虽然没用layout(location = 0)
显式修饰outValue,但是输出属性基于0递增,所以,第一个用0表示。 - 参数
gpuOutputBuffer
:指定了用于绑定的VBO,即GPU往指定的位置上写计算结果数据。
8、禁用光栅化等渲染管线后续操作
因为不绘图,光栅化、片段着色器、深度测试等渲染管线后续操作是多余的,故禁用,节省资源。值得一提的是,现代GPU已不再区分顶点处理单元和片段处理单元,它们统称统一处理单元(Uniform Process Unit),即,同一个处理单元,会先处理顶点着色器的代码,再执行片段着色器的代码。
glUseProgram(_program);
glEnable(GL_RASTERIZER_DISCARD);
glUseProgram(_program);
在链接后立即调用,之后再用Buffer给GPU准备数据,也可以的,在绘图调用前上传数据即可,顺序不影响执行结果。
9、进入transform feedback模式glBeginTransformFeedback(GL_POINTS);
,虽然指定为点处理方式,实际上看不到这些点的处理流程。另外,应根据业务需求使用正确的绘制模式,并与glDrawArrays
保持一致。
10、绘图调用,glDrawArrays(GL_POINTS, 0, 5);
绘图方式与glBeginTransformFeedback设置相同。
11、结束transform feedback模式,glEndTransformFeedback();
。
12、同步GPU
因CPU与GPU之间为异步执行关系,那么映射内存前,需确保前面的ES指令都执行完。有三种方式同步:
- glFlush() 刷新ES命令队列,在有限时间内强制执行GPU指令队列。
- glFinish()阻塞当前线程并等待所有GPU指令执行完毕。
- glWaitSync()需配合同步对象,编程略为麻烦,后续文档再介绍,在此不详述。
简单起见,这里使用glFinish()
。
13、映射GPU内存为读取GPU处理结果作准备
现在需要映射GPU的Transform Feedback缓冲区空间到CPU地址空间。GL操作起来非常方便:
GLfloat feedback[5];
glGetBufferSubData(GL_TRANSFORM_FEEDBACK_BUFFER, 0, sizeof(feedback), feedback);
ES没glGetBufferSubData
,操作要曲折些。在ES,可使用glMapBufferRange
映射GPU内存。
float *gpuMemoryBuffer = glMapBufferRange(GL_ARRAY_BUFFER, 0, sizeof(data), GL_MAP_READ_BIT);
14、读取GPU计算结果
if (!gpuMemoryBuffer) {
printf(@"gpuMemoryBuffer == null");
}
for (int i = 0; i < 5; ++i) {
printf("gpuMemoryBuffer[%d] = %f\\\\\\\\t", i, gpuMemoryBuffer[i]);
}
printf("\\\\\\\\n");
以数据源'GLfloat data[] = {2, 3, 4, 5, 6};'为例,在Vertex Shader对它作开方运算,打印的值如下:
gpuMemoryBuffer[0] = 1.414214
gpuMemoryBuffer[1] = 1.732051
gpuMemoryBuffer[2] = 2.000000
gpuMemoryBuffer[3] = 2.236068
gpuMemoryBuffer[4] = 2.449490
15、解除映射,glUnmapBuffer(GL_ARRAY_BUFFER);
。
需要说明的是,第7步使用glBindBuffer(GL_ARRAY_BUFFER, _gpuOutputBuffer);
将GPU输出缓冲区绑定为GL_ARRAY_BUFFER
类型,所以后续的BufferData、MapBufferRange和UnmapBufferRange都使用同一参数。然而,对于Transform Feedback,使用GL_TRANSFORM_FEEDBACK_BUFFER
也可读出数据,只要保持当前绑定的缓冲区一致。
3、常见问题
3.1、GLKit不使用GLView导致Transform Feedback操作无效
self.context = [[EAGLContext alloc] initWithAPI:kEAGLRenderingAPIOpenGLES3];
if (!self.context) {
NSLog(@"This application requires OpenGL ES 3.0");
abort();
}
// GLKView *view = (GLKView *)self.view;
// view.context = self.context;
// view.drawableDepthFormat = GLKViewDrawableDepthFormat24;
[EAGLContext setCurrentContext:self.context];
如果只作计算,一般认为不需要被注释的内容,毕竟不做显示。然而,经测试发现也导致读不到Transform Feedback回来的结果,program、shader等都正常工作。
3.2、Transform Feedback结果缓冲区数据为0
使用glMapBufferRange
返回的buffer不为NULL,读取时却是0.0f。可使用glMapBufferRange
映射GPU写缓冲区,看看数据是否正常上传,如下代码所示,只打印,不改变上传的数据。
float *gpuInputMemoryBuffer = glMapBufferRange(GL_ARRAY_BUFFER, 0, sizeof(data), GL_MAP_WRITE_BIT);
if (! gpuInputMemoryBuffer) {
printf("gpuInputMemoryBuffer == null");
}
for (int i = 0; i < 5; ++i) {
printf("gpuInputMemoryBuffer[%d] = %f\\\\\\\\t", i, gpuInputMemoryBuffer[i]);
}
printf("\\\\\\\\n");
glUnmapBuffer(GL_ARRAY_BUFFER);
直接上传数据不可使用此方式打印数据,直接上传数据示例代码如下。
glEnableVertexAttribArray(0);
glVertexAttribPointer(0, 1, GL_FLOAT, GL_FALSE, 0, gpuInputDataArray);
另一种情况是,使用BufferData、glVertexAttribPointer又指定数据源,如:
GLuint vbo;
glGenBuffers(1, &vbo);
glBindBuffer(GL_ARRAY_BUFFER, vbo);
glBufferData(GL_ARRAY_BUFFER, sizeof(data), data, GL_STREAM_DRAW);
// 上传数据时指定数据源参数
glVertexAttribPointer((GLuint) inValuePos, 1, GL_FLOAT, GL_FALSE, 0, data);
不建议这么操作。
简单数学计算无法体现GPU的优势,通常图像处理等场合会有较为明显的GPU处理速度比CPU快的现象。
已编写的其他Transform Feedback相关文档:
- NDK OpenGL ES 3 编译C/C++可执行文件(无需JNI调用)描述了NDK了配置OpenGL ES 3.0及以上版本开发环境并C++可执行文件,Android上使用Transform Feedback可参考此文档。
- 解决苹果开发者论坛的求助帖(Transform Feedback doesn't write, crashes)修正其代码中内存映射指定的空间大小有误导致映射出错的问题。
- OpenGL ES 3.0使用Transform Feedback实现图像对比度调整详细介绍如何只用顶点着色器进行图像处理、读取结果图像生成UIImage。
推荐阅读
- Exploring GPGPU on iOS
- Transform feedback
- TransformFeedback java实现
- Noise-Based Particles, Part II
- Particle System using Transform Feedback
- Boids
- glMapBufferRange returning all zeros in Android
- glMapBufferRange() returns all zeros in Android OpenGLES 3.0 using TrasnformFeedback