OpenGL ES 第四篇OpenGL ES设计指南
OpenGL ES设计指南
看完前面几篇后,你应该对iOS应用使用OpenGL ES有基本的掌握,在这里我会帮助你为你的应用为了更好的性能设计渲染引擎。这里将会引进关于渲染设计的关键概念。当然后面也会有更好的练习和性能技巧的信息。
如何可视化OpenGL ES
接下来将会描述OpenGL ES可视化设计的两个方面:客户端-服务端体系结构和管道。在计划和评估你应用的架构这两个方面都是非常有用的。
- OpenGL ES作为客户端-服务端架构
下图就展示了OpenGL ES作为客户端-服务端的架构。你的应用传达状态改变、纹理、顶点数据、渲染命令给OpenGL ES客户端。客户端翻译这些数到一个图形硬件可以理解的格式,然后转发给GPU。这些过程会增加应用图形性能的开销。要想获得好的性能需要仔细的管理这些开销。一个设计良好的应用可以减少它对OpenGL ES的调用频率,使用与硬件相适应的数据格式来降低转换成本,并小心的管理它自己和OpenGL ES之间的数据流。
OpenGL ES作为图形管道
下图展示OpenGL ES作为图形管道。你的应用配置图形管道,然后执行绘制命令发关顶点数据给管道。管道的后续阶段运行一个顶点着色器来处理顶点数据,将顶点组装成基元,将基元光栅化为片段,运行一个片段着色器来计算每个片段的颜色和深度值,并将片段混合到一个帧缓冲区中以供显示。使用管道作为思维模型来确定你的应用如何工作生成新的一帧。你的渲染器设计包括着编写着色程序来处理管道的顶点和片段阶段,组织你输入到这些程序中的顶点和纹理数据,配置OpenGL ES状态来驱动固定功能。图形管道中的各个阶段可以同时计算它们的结果--例如,你的应用程序可能准备了新的基元,而图形硬件的独立部分则对以前提交的几何图形执行顶点和分段计算。但是后面的阶段取决于前面阶段的输出。如果任一阶段执行了太多的任务或执行速度太慢,其它管道处于空闲状态,直到最慢的阶段完成其工作。一个设计良好的应用程序应根据图形硬件功能平衡每个管道的阶段工作。
OpenGL ES版本和渲染器架构
iOS支持3个版本的OpenGL ES。最新的版本提供更灵活的功能,允许你实现渲染算法,包括高质量的视觉效果且不会影响性能。
- OpenGL ES 3.0
3.0是从iOS 7 开始的。你的应用程序可以使用OpenGL ES 3.0中 引入的特性来实现高级图形编程技术(以前只能在桌面级硬件和游戏控制台上使用),以获得更快的图形性能和引人注目的视觉效果。 有关完整描述请看OpenGL ES API Registry。下面会介绍些3.0关键特性。
3.0版本的着色,增加了一些新特性,如统一块、32位整数和附加的整数操作,用于在顶点和分段着色器程序中执行更多通用计算任务。要在着色器程序中使用新特性,源代码必须以#version 330 es开头,与2.0保持兼容。
多渲染目标:为了能够有多个渲染目标,你可以创建分段着色器,可以同时给多个framebuffer附件写入。这个特性能够使用高级渲染算法如 deffered shading,在你的应用中首先渲染一组纹理来存储几何数据,然后执行一个或多个从这些纹理读取的阴影通道,并执行光照计算来输出最终的图像。因为这种方法预先计算了光照计算的输入,所以向场景中添加更多的光照所增加的性能成本小得多。deferred shading 算法需要多个渲染目标的支持,如下图,达到合理的性能。否则,渲染到不同的纹理需要为每个纹理有独立的绘制通道。
// 不是为framebuffer创建一个单一颜色附件,而是创建多个。然后调用 glDrawBuffers函数来指定在哪个framebuffer附件中呈现
// 创建多个渲染目标
// 附加纹理(前面几篇中创建好的)到framebuffer
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, GL_TEXTURE_2D, _colorTexture, 0);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT1, GL_TEXTURE_2D, _positionTexture, 0);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_COLOR_ATTACHMENT2, GL_TEXTURE_2D, _normalTexture, 0);
glFramebufferTexture2D(GL_DRAW_FRAMEBUFFER, GL_DEPTH_STENCIL_ATTACHMENT, GL_TEXTURE_2D, _depthTexture, 0);
// 指定framebuffer附件渲染
GLenum targets[] = {GL_COLOR_ATTACHMENT0, GL_COLOR_ATTACHMENT1, GL_COLOR_ATTACHMENT2};
glDrawBuffers(3, targets);
// 当你的应用发出绘制命令时,分段着色器会决定为每个渲染目标的每个像素输出什么颜色(或非颜色数据)。
// 下面的代码显示了一个基本的分段着色器渲染到多个目标,通过分配分段输出变量,他们的位置与上面代码段相匹配。
#version 300 es
uniform lowp sampler2D myTexture;
in mediump vec2 texCoord;
in mediump vec4 position;
in mediump vec3 normal;
layout(location = 0) out lowp vec4 colorData;
layout(location = 1) out mediump vec4 positionData;
layout(location = 2) out mediump vec4 normalData;
void main() {
colorData = texture(myTexture, texCoord);
positionData = position;
normalData = vec4(normalize(normal), 1.0);
}
- 变换反馈(transform feedback)
图形硬件为向量处理提供了高并行优化的架构。你可以更好的利用硬件的新变换反馈功能,它可以让你在GPU内存中捕获从顶点着色器到缓冲对象的输出。你可以从一个渲染通道抹捕获数据使用在另一个通道,或禁用部分图形管道同时为能用的计算使用变换反馈。从变换反馈中受益的技术是动画粒子效果。一个渲染通用架构的粒子系统如下图。首先,应用建立粒子模拟器的初始状态。然后,为每一帧的渲染,应用程序运行一个模拟器,更新每个粒子模拟的位置、方向和速度,然后绘制代表粒子当前状态的可视化资源。
一般的,应用实现粒子系统是在CPU上运行模拟器,将模拟结果存储在一个顶点缓冲区中,用于渲染粒子艺术。然而传输顶点缓存内容给GPU内存是非常花费时间。变换反馈通过优化现代GPU硬件中可用的并行架构的能力,有效的解决了这个问题。使用变换反馈,你设计的渲染引擎解决问题更高效。下图概述了应用程序如何配置OpenGL ES图形管道实现粒子系统动画。因为OpenGL ES将每个粒子及其状态表示为一个顶点,因此GPU的顶点着色器阶段可以同时运行多个粒子仿真。因为顶点缓存包含粒子状态数据在帧之间重用,传输数据到GPU内存的耗时只发生一次,在初始化时。
- 初始化时,创建一个顶点缓存,同时填充数据,数据包含在模拟中所有粒子初始状态
- 在GLSL顶点着色程序中实现粒子模拟,通过绘制包含粒子位置数据顶点缓存区的内容来运行每一帧。用变换反馈渲染,调用glBeginTransformFeedback函数(恢复正常绘制前)。使用glTransformFeedbackVaryings函数指定哪个着色器的输出可以通过变换反馈捕获,然后使用glBindBufferBase或glBindBufferRanges函数和GL_TRANSFORM_FEEDBACK_BUFFE缓存类型指定将要被捕获进的缓存。调用glEnable(GL_RASTERRIZER_DISCARD)禁用栅格化(包括管道的子阶段)。
- 为了显示模拟结果,使用包含粒子位置的顶点缓冲区作为第二遍绘图的输入,再次启用栅格化(以及管道的其余部分),并使用顶点和分段着色适当地呈现应用程序的可视化内容。
- 在下一帧中,使用上一帧仿真步骤的顶点缓冲区输出作为下一仿真步骤的输入。
- OpenGL ES 2.0
2.0版本用可编程着色器提供更灵活的图形管道,在当前所有iOS设备可用。许多特性正式被引进3.0规范可用于iOS设备是通过2.0的扩展,因此你可实现很多高级图形程序技巧依然会兼容大部分设备。
设计一个高性能的OpenGL ES应用
总而言之,设计一个好的程序需要:1. 利用OpenGL ES管道的并行性;2.管理好应用与图形硬件间的数据流;下图显示一个使用OpenGL ES来对显示执行动画的应用程序的流程。
当应用启动时,它要做的第一件事是初始化那些在应用的生命周期内不打算改变的资源。理想情况下,应用将这些资源封装到OpenGL ES对象中。其目的是创建任何在应用程序运行期间(如生命周期的一部分,如游戏中某一关卡的持续时间)可以保持不变的对象,以增加初始化时间换取更好的性能。复杂命令或状态的更改应该替换为OpenGL ES对象,这些对象可以与单个函数调用一起使用。例如,配置固定功能管道可能需要几十个函数调用。相反,在初始化时编译一个图形着色器,并在运行时通过一个函数调用切换到它。创建或修改OpenGL ES对象的开销较大,此时都应该创建为静态对象。
呈现循环处理你打算呈现给OpenGL ES上下文的所有项,然后将结果呈现给显示器。在动画场景中,每一帧都会更新一些数据。在上图的内部渲染循环中,应用程序在更新呈现资源和提交使用这些资源的绘图命令之间进行切换。这个内部循环的目标是平衡工作负载,使CPUtkgGPU并行工作,防止应用和OpenGL ES同时访问相同的资源。在iOS上,修改OpenGL ES对象如果不在帧的开始或结束时执行,代价可能会很大。
上图内部循环的一个重要目标就是避免从OpenGL ES拷贝数据到应用。从GPU拷贝结果到CPU可能非常慢。如果拷贝的数据稍后也用作呈现当前帧过程的一部分,就像显示在中间的渲染循环,你的应用将阻塞直到所有先前提交的命令完成。在应用提交帧需要的所有绘制命令后,它呈现结果到屏幕。一个非交互应用会拷贝最终的图像到应用内存进行更深的处理。最终,当应用准备退出或一个主任务使它停止,将会释放OpenGL ES对象来提供额外可用资源,无论是当前应用或其它应用程序。
以上描述可以总结为: 1. 只要可行就创建静态资源;2. 内部渲染循环在修改动态资源和提交渲染命令间切换。尝试避免修改动态资源,除非在一帧的开始或结束;3. 避免读取中间渲染结果返回给应用;
避免同步和清除操作
OpenGL ES规范没有要求立即执行实现命令。往往,命令被排队送往命令缓冲区,稍后由硬件执行。通常OpenGL ES会等到应用将命令排队后,才将这些发送命令给硬件批处理,这样会更有效。然而,有些OpenGL ES函数需要立即清除命令缓存。其它的函数不仅刷新缓存命令,而且还会阻塞,直到之前提交的命令已完成,才返回对应用的控制。使用清除和同步命令仅在必要的时候。过渡使用刷新或同步命令可能会导致应用程序在等待硬件完成渲染时暂停。
- 以下几种情况需要OpenGL ES提交命令缓冲区给硬件执行:
- 函数 glFlush 发送命令缓冲区给图形硬件。它将阻塞直到命令被提交到硬件,但不会等到所有命令执行完成。
- 函数 glFinish 清除命令缓冲区,同时等待之前所有提交的命令在图形硬件上执行完成。
- 函数获取到 framebuffer 内容(如 glReadPixels)也要等待提交的命令完成
- 命令缓冲区满了
有效使用 glFlush
在某些桌面OpenGL 实现中,同期调用glFlush函数去有效平衡CPU和GPU工作是很有用的,但在iOS中却不是这样。iOS图形硬件实现是基于片段的延迟渲染算法依赖于对场景中所有顶点数据的一次性缓冲,因此可以对其进行最优处理以去除隐藏表面。有两种典型的情况OpenGL ES应用应当调用glFlush 或 glFinish函数。 1. 应用进入后台; 2. 你的应用在多个上下文中分享OpenGL ES对象(如顶点缓存或纹理),你要调用 glFlush函数同步访问这些资源。如果你分享OpenGL ES对象同其它iOS APIs(如 Core Image)也建议这么做。
避免查询OpenGL ES状态
调用 glGet*() 包括glGetError(),可能需要OpenGL ES在检索任何状态变量之前执行前面的命令。这种同步迫使图形硬件与CPU同步运行,减少了并行机会。为了避免这种情况,你需要维护自己需要查询的任何状态的副本,并直接访问它,而不是调用OpenGL ES。当发生错误时,OpenGL ES设置一个错误标志。这些错误和其它错误会出现在Xcode的OpenGL ES框架调试器或分析器中。你应该使用这些工具,而不是glGetError函数,后者频繁调用时会降低性能。其它查询,如glCheckFramebufferStatus,glGetProgramInfoLog,glValidateProgram通常也只在开发和调试时有用。在应用程序发布版本中省略对这些函数的调用。
使用OpenGL ES管理资源
许多OpenGL数据可以直接存储在OpenGL ES渲染上下文及其关联的sharegroup对象中。OpenGL ES实现可以自由地将数据转换为最适合图形硬件的格式。这可以显著提高性能,尤其在数据改变频繁时。你的应用程序还可以向OpenGL ES提供关于它打算如何使用数据的提示。OpenGL ES可以使用这些提示处理数据更有效。如静态数据可能被放置在图形处理器能够轻松获取的内存中,甚至可能被放置到专用的图形内存中。
使用双倍缓存避免资源冲突
资源冲突发生在你的应用和OpenGL ES在同一时间访问OpenGL ES对象。当一个参与者试图修改另一个参与者正在使用的OpenGL ES对象时,他们可能会阻塞,直到该对象不再使用为止。一旦其中一个开始修改它时,另一个在修改完成之前是不能访问该对象。或者OpenGL ES可以隐式复制对象,以便两个参与者都可以继续执行命令。虽然两个做都是安全的但都有可能成为应用的瓶颈。下图展示的例子,只有一个纹理对象,OpenGL ES和应用都想使用它,当应用程序改变它,必须要等待,直到之前提交的绘图命令完成,CPU同步到GPU。
为了解决这个问题,你的应用在改变对象和绘制它之间要执行额外的工作。但如果你的应用没有额外的任务可执行,可以显式的两份独立大小的对象,一个参与者读取一个对象,另一个修改另一个对象。下图显示了双倍缓存的方法。GPU操作一个纹理,CPU修改另一个。在开始启动后,CPU和GPU都没有闲置。这个解决方案对几乎适用于任何类型的OpenGL ES对象。
注意OpenGL ES的状态
OpenGL ES的实现维护了一个复杂数据状态的集合,包括使用glEnable或glDisable函数设置的开关、当前着色器程序及统一变量、当前绑定的纹理单元、当前绑定的顶点缓冲区及其启用的顶点属性。硬件有一个当前状态,它被编译并延迟缓存。切换状态的开销是很大的,所以最好是设计应用程序尽量少的状态切换。不要设置已经设置的状态,一旦一个特性启用,就不需要 再次启用。例如,如果你调用glUniform用相同的参数多于一次,OpenGL ES可能不会检查是否已经设置了相同的统一状态。它只是更新状态值,即使该值与当前值相同。通过使用专用的设置或关闭例程避免设置不必要的状态,而不是将此类调用放入绘图循环中。设置和关闭例程对于打开和关闭实现特定视觉效果的特性也很有用,例如在绘制线框轮廓的纹理多边型。
封装状态到OpenGL ES对象,为了减少状态改变,创建收集OpenGL ES状态改变的到一个对象的多个对象,该对象可以通过单个函数调用绑定。例如,顶点数组对象存储着配置多个顶点属性到一个对象。
改变OpenGL ES状态不会立即有效果。相反,当你提交一个绘制命令时,OpenGL ES执行必要的工作去绘制一组状态值。你可以通过最小化状态改变减少CPU消耗时间重新配置图形管道。例如,保持一个状态矢量在你的应用,仅在调用间状态改变设置相应的OpenGL ES状态。另一个有用的算法是状态排序---跟踪你需要执行的绘图操作和每个操作所需的状态更改量,然后对它们进行排序,以便连续使用相同的状态执行操作。