OpenGL ES 3.0使用Transform Feedback进行通用计算并读取结果

修订: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。

OpenGL ES 3.0使用Transform Feedback进行通用计算并读取结果_第1张图片
Transform Feedback应用示例

使用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)桥接的配置。

  1. 配置EGL上下文
  2. 提供用于计算的顶点着色器
  3. 配置program
  4. 在链接program前配置transform feedback输出属性名
  5. 链接program
  6. 配置GPU的输入数据缓冲区并上传数据至GPU
  7. 配置GPU的输出数据缓冲区并与transform feedback绑定
  8. 禁用光栅化等渲染管线后续操作
  9. 进入transform feedback模式
  10. 绘图调用
  11. 结束transform feedback模式
  12. 同步GPU
  13. 映射GPU内存
  14. 读取GPU计算结果
  15. 解除映射

操作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指令都执行完。有三种方式同步:

  1. glFlush() 刷新ES命令队列,在有限时间内强制执行GPU指令队列。
  2. glFinish()阻塞当前线程并等待所有GPU指令执行完毕。
  3. 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

你可能感兴趣的:(OpenGL ES 3.0使用Transform Feedback进行通用计算并读取结果)