万书网 > 文学作品 > 深度探索Linux操作系统 > 8.4 3D渲染

8.4 3D渲染



运行在X上的2D程序,都将绘制请求发给X服务器,由X服务器来完成绘制。但是对于3D图形的绘制,X应用需要通过套接字向X服务器传递大量的数据,这种机制严重影响了图形的渲染效率。为了解决效率问题,X的开发者们设计了DRI机制,即X应用不再将绘制图形的请求发送给X服务器了,而是由应用自行绘制。

在Linux平台上,OpenGL的实现是Mesa,所以在本节中,我们结合Mesa,探讨3D的渲染过程。我们可以认为Mesa分为两个关键部分:

◆  一部分是一套兼容OpenGL标准的实现,为应用程序提供标准的OpenGL  API。

◆  另外一部分是DRI驱动,通常也被称为3D驱动,其中包括Pipleline的软件实现,也就是说,即使GPU没有任何3D计算能力,那么Mesa也完全可以使用CPU完成3D渲染功能。3D驱动还负责将3D渲染命令翻译为GPU可以理解并能执行的指令。不同的GPU有各自的“指令集”,因此,在Mesa中不同的GPU都有各自的3D驱动。

Pipeline最后将生成好的像素阵列输出到帧缓冲,但是这还不够,因为最后的输出需要显示到屏幕上。而屏幕的显示是由具体的窗口系统控制的,因此,帧缓冲还需要与具体的窗口系统相结合。但是X的核心协议并不包含OpenGL相关的协议,因此,开发者们开发了GL的扩展GLX(GL  Extension)。为了支持DRI,开发者们又开发了DRI扩展。显然,GLX以及DRI扩展在X和Mesa中均需要实现。

基本上,运行在X窗口系统上的OpenGL程序的渲染过程,可以划分为三个阶段,如图8-6所示。

图 8-6 3D渲染架构图

1)应用创建OpenGL的上下文,包括向X服务器申请创建帧缓冲。应用为什么不自己直接向内核的DRM模块请求创建帧缓冲呢?从技术上讲,应用自己请求DRM创建请求创建帧缓冲没有任何问题,但是为了将帧缓冲与具体的窗口系统绑定,应用只能委屈一下,放低姿态请求X服务器为其创建帧缓冲。这样,X服务器就掌握了应用的帧缓冲的一手材料,在需要时,将帧缓冲显示到屏幕。帧缓冲是应用程序的“画板”,因此创建完成后,X服务器需要将帧缓冲的BO的信息返回给应用。

2)应用程序建立数学模型,并通过OpenGL的API将数学模型的数据写入顶点缓冲(vertex  buffer);更新GPU的状态,如指定后缓冲,用来存储Pipeline输出的像素阵列;然后启动Pipeline进行渲染。

3)渲染完成后,应用程序向X服务器发出交换(swap)请求。这里的交换有两种方式,一种是复制(copy),所谓复制就是将后缓冲中的内容复制到前缓冲,这是由GPU中BLT引擎负责的。但是复制的效率相对较低,所以,开发者们又设计了一种称为页翻转(page  flip)的模式,在这种模式下,不需要复制动作,而是通过GPU的显示引擎控制显示控制器扫描哪个帧缓冲,这个被扫描的缓冲此时扮演前缓冲,而另外一个不被扫描的帧缓冲则作为应用的“画板”,也就是所说的后缓冲。

接下来我们就围绕这三个阶段,讨论3D程序的渲染过程。



8.4.1 创建帧缓冲


在2D渲染中,渲染过程都由X服务器完成,所以毫无争议,前缓冲由而且只能由X服务器创建。但是对于DRI程序来说,其渲染是在应用中完成,应用当然需要知道帧缓冲,但是X服务器控制着窗口的显示,所以X服务器也需要知道帧缓冲。所以,帧缓冲或者由X服务器创建,然后告知应用;或者由应用创建,然后再告知X服务器。X采用的是前者。

虽然OpenGL中的帧缓冲的概念与2D相比有些不同,但本质上并无差别,帧缓冲中的每个缓冲都对应着一个BO。为了管理方便,Mesa为帧缓冲以及其中的各个缓冲分别抽象了相应的数据结构,代码如下:



其中,结构体gl_framebuffer是帧缓冲的抽象。结构体gl_renderbuffer是颜色缓冲、深度缓冲等的抽象。gl_framebuffer中的数组Attachment中保存的就是颜色缓冲、深度缓冲等。

在具体的3D驱动中,通常会以gl_renderbuffer作为基类,派生出自己的类。如对于Intel  GPU的3D驱动,派生的数据结构为intel_renderbuffer:



其中指针mt间接指向缓冲区对应的BO。

如同在Intel  GPU的2D驱动中,使用结构体intel_pixmap封装了BO一样,Intel  GPU的3D驱动也在BO之上包装了一层intel_region。intel_region中除了包括BO外,还包括缓冲区的一些信息,如缓冲区的宽度、高度等:



当OpenGL应用调用glXMakeCurrent时,就开启了创建帧缓冲的过程,这个过程可分为三个阶段:

1)OpenGL应用向X服务器请求为指定窗口创建帧缓冲对应的BO。帧缓冲中包含多个缓冲,所以当然是创建多个BO了。

2)X服务器收到应用的请求后,为各个缓冲创建BO。在创建完成后,将BO的名字等相关信息发送给应用。

3)应用收到BO信息后,将更新GPU的状态。比如告诉GPU画板在哪里。

1.应用请求X服务器创建BO

帧缓冲与具体的GPU密切相关,因此创建帧缓冲的发起在3D驱动中。以i915系列的3D驱动为例,发起创建帧缓冲的函数为intelCreateBuffer:



函数intelCreateBuffer先后创建了帧缓冲对象和帧缓冲中包含的各个“子”缓冲对象,并将各“子”缓冲对象加入到帧缓冲对象的数组Attachment中。但是并不是OpenGL中规定的所有的缓冲对象都需要创建,所以函数intelCreateBuffer需要根据具体情况创建如前缓冲、后缓冲、深度缓冲等对象。注意,这里所谓的创建缓冲对象,仅仅是搭建起了一个空架子而已,帧缓冲尚未与具体的BO绑定。

一旦应用调用glXMakeCurrent切换自己为当前应用,glXMakeCurrent将调用3D驱动中的函数intel_update_renderbuffers请求X服务器创建指定X窗口的各个缓冲区的BO:



其中,函数intel_query_dri2_buffers_with/no_separate_stencil向X服务器申请为ID为drawable的窗口创建帧缓冲。以intel_query_dri2_buffers_with_separate_stencil为例:



函数intel_query_dri2_buffers_with_separate_stencil将帧缓冲中的各个缓冲组织为一个数组attachments,其格式是缓冲的ID加上缓冲的色深,后面组织X请求将使用这个数组attachments。然后调用getBuffersWithFormat向X服务器请求创建这些缓冲的BO。在Mesa端的DRI扩展中,getBuffersWithFormat最终调用的函数是DRI2GetBuffersWithFormat:



函数DRI2GetBuffersWithFormat首先创建一个X_DRI2GetBuffersWithFormat类型的X请求,根据前面组织的数组attachments,即申请创建的缓冲的信息,组织X请求的消息体,消息体中包含各缓冲的ID和色深。

然后调用Xlib的接口_XReply将请求发送给X服务器,并等待请求的返回。

在X服务器创建BO后,会将BO信息返回给应用,X服务器创建BO的过程我们下节讨论。根据代码我们看到,在返回的BO信息中最关键的一项就是BO的名称。回忆8.2.2节的讨论,我们谈到无论是X服务器还是应用,均使用名称访问BO。所以,这里返回的BO的名称就是为了使DRI应用通过这个名称访问BO。看到名称,我们习惯上将其理解为字符串,实际上在内核的DRM模块中,为BO的名称分配的是一个数字。

2.X服务器创建BO

X服务器中处理OpenGL应用为帧缓冲创建BO请求的函数是ProcDRI2GetBuffers  WithFormat:



函数ProcDRI2GetBuffersWithFormat首先从应用的请求中提取attachments,然后调用函数DRI2GetBuffersWithFormat创建BO,最后通过函数send_buffers_reply将BO的信息发送给应用。

函数DRI2GetBuffersWithFormat将调用函数do_get_buffers为帧缓冲创建BO:



函数do_get_buffers中的变量count为应用请求创建BO的数量,显然,函数do_get_buffers是在循环为窗口的缓冲区创建BO。其中allocate_or_reuse_buffer调用I830DRI2CreateBuffer为缓冲区创建BO:



在前面讨论2D渲染时,我们已经看到,X服务器启动时,2D驱动在初始化输出设备时已经创建了前缓冲的BO。因为各个窗口是共享这个前缓冲的,因此,如果DRI应用申请为前缓冲创建BO,则I830DRI2CreateBuffer就不必创建了,其调用函数get_front_buffer直接查找前缓冲的BO,如代码第5~8行所示。

如果函数I830DRI2CreateBuffer执行到第10行代码时,pixmap依然空,则说明这次不是为前缓冲创建BO,于是调用函数CreatePixmap为其他缓冲创建BO。在UXA中,CreatePixmap指向函数intel_uxa_create_pixmap:



函数drm_intel_bo_alloc_for_render是库libdrm提供的接口,其请求内核的DRM模块为缓冲区创建BO。

创建好BO后,函数I830DRI2CreateBuffer使用库libdrm提供的接口pixmap_flink,请求内核的DRM模块为BO命名,见第16行代码。

在创建完缓冲区的BO后,让我们回到函数ProcDRI2GetBuffersWithFormat,其将调用send_buffers_reply将BO的相关信息发送给应用程序:



仔细观察send_buffers_reply,可见,即使应用向X服务器发出了索要前缓冲的BO的申请,X服务器也不会将真正的前缓冲的BO的信息发送给应用程序。事实上,对于运行在X窗口系统上的OpenGL应用来说,尽管应用程序有可能要求直接绘制在前缓冲上,但是X服务器发给OpenGL应用的只是一个伪前缓冲,和普通的后缓冲没有本质区别。从这里也可以看出,X不允许DRI应用不通过X直接在前缓冲上绘制,X不希望应用把屏幕显示搞乱,X要对前缓冲有绝对的控制权。如果读者熟悉Linux,一定知道第1版的DRI,在开启符合管理器后,运行DRI应用时,那个著名的glxgears转动的齿轮不受复合管理器管理的bug。

3.更新GPU状态

系统中可能存在多个OpenGL程序并行运行但是只有一个GPU的情况。因此,GPU要分时给不同的OpenGL程序使用。如同进程切换时,CPU需要切换上下文一样,在对不同的OpenGL程序进行渲染时,GPU也需要在不同程序之间切换。

以帧缓冲为例,每个OpenGL程序都有自己的帧缓冲。但是只有当前进行绘制的OpenGL应用的帧缓冲才是GPU的目标帧缓冲。因此,当不同的OpenGL程序进行切换时,GPU需要切换记录帧缓冲地址的寄存器,使其指向当前正在进行绘制的程序的帧缓冲。

以Intel  i915系列GPU为例,在其3D驱动中,对应GPU状态的结构体为struct  i915_hw_state:



结构体i915_hw_state使用一系列的数组来记录GPU的状态,其中指针draw_region指向的就是保存输出的图像的像素阵列的BO。

应用程序从X服务器获取了各个缓冲区的BO后,需要更新GPU中帧缓冲相关的状态。以i915的3D驱动中缓冲区更新为例,更新GPU的帧缓冲状态的函数是i915_update_draw_buffer:



第5行的变量irb显然是指向一个颜色缓冲区。

Intel  GPU的3D驱动中采用Mipmap的方式保存intel_region,Mipmap是一种为了加快渲染速度和减少图像锯齿,将贴图处理成由一系列被预先计算和优化过的图片的技术。因此,第6行代码中的"irb->mt->region"就是指向封装颜色缓冲BO的intel_region对象。
那么_ColorDrawBuffers中的第0个缓冲指向的是哪个颜色缓冲呢?看看下面代码片段:



根据上面的代码片段可见,这个颜色缓冲就是后缓冲。

我们继续看函数i915_update_draw_buffer中的函数set_draw_region,对于i915的3D驱动,该函数指针指向i915_set_draw_region:



其中,第7~8行代码中调用的函数intel_region_reference比较简单,就是将i915_hw_state中的draw_region设置为color_regions[0],其就是我们刚刚在函数i915_update_draw_buffer中讨论的后缓冲。

在考察第10~12行代码调用的函数i915_set_buf_info_for_region前,先来看一下传给这个函数的3个参数。根据第19行的宏定义,可见第1个参数就是i915_hw_state中数组Buffer的首地址;第2个参数color_regions[0]是后缓冲;第3个参数从名字上可以猜出大概是GPU用来标识后缓冲的ID。了解了参数后,我们来看一下这个函数的具体代码:



显然,函数i915_set_buf_info_for_region就是设置i915_hw_state中数组Buffer的前两个元素的值。第一个元素被赋值为GPU指令_3DSTATE_BUF_INFO_CMD;第二个元素被赋值为标识后缓冲的ID。

更新了i915_hw_state中的状态信息后,函数i915_set_draw_region调用I915_STATECHANGE将状态信息组织到批量缓冲。GPU将在进行绘制之前,从批量缓冲中读取这些信息,并更新自身的状态。宏I915_STATECHANGE最终调用函数i915_emit_state组织批量缓冲:



根据第5~6行代码,批量缓冲中的前两个元素分别为i915_hw_state中Buffer数组中的第一个和第二个元素。我们刚刚讨论过,这两个元素分别是GPU指令_3DSTATE_BUF_INFO_CMD和GPU用来标识后缓冲的ID的。

笔者没有找到有关GPU指令_3DSTATE_BUF_INFO_CMD的参考,但是根据上面代码第16~22行的宏定义,我们可以猜出一二:

1)_3DSTATE_BUF_INFO_CMD是个指令ID,应该是告诉GPU更新相关缓冲的信息。

2)在指令码之后,紧接的第一个参数中至少应该包含要更新的缓冲区的ID,这里BUF_3D_ID_COLOR_BACK应该是GPU内部用来标识后缓冲的ID。我们看到这个ID大约占据从24位开始的几位,如011对应的是后缓冲,111对应的是深度缓冲。

3)既然通知GPU更新后缓冲的地址,当然需要将后缓冲所在的BO告知GPU了。所以指令码之后的第二个参数应该是更新的缓冲的BO。当然了,这里要使用BO在GPU地址空间的地址。上面代码第8~9行的宏正是在批量缓冲中写入了后缓冲BO的地址。

事实上,除了更新了GPU中后缓冲的信息外,也更新了GPU的其他状态,这里不再一一讨论。



8.4.2 渲染Pipleline

与2D渲染相比,3D渲染要复杂得多。就如同有些复杂的绘画过程,要分成几个阶段一样,OpenGL标准也将3D的渲染过程划分为一些阶段,并将由这些阶段组成的这一过程形象地称为Pipleline。

应用程序建立基本的模型包括在对象坐标中的顶点数据、顶点的各种属性(比如颜色),以及如何连接这些顶点(如是连接成直线还是连接为三角形),等等,统一存储在顶点缓冲中,然后作为Pipeline的输入,这些输入就像原材料一样,经过Pipeline这台机器的加工,最终生成像素阵列,输出到后缓冲的BO中。

OpenGL的标准规定了一个参考的Pipeline,但是各家GPU的实现与这个参考还是有很多差别的,有的GPU将相应的阶段合并,有的GPU将个别阶段又拆分了,有的可能增加了一些阶段,有的又砍了一些阶段。但是,大体上整个过程如图8-7所示。

图 8-7 Pipeline

(1)顶点处理

OpenGL使用顶点的集合来定义或逼近对象,应用程序建模实际上就是组织这些顶点,当然也包括顶点的属性。Pipeline的第一个阶段就是顶点处理(vertex  operations),顶点处理单元将几何对象的顶点从对象坐标系变换到视点坐标系,也就是将三维空间的坐标投影到二维坐标,并为每个顶点赋颜色值,并进行光照处理等。

(2)图元装配

显然,很多操作处理是不能以顶点单独进行处理的,比如裁减、光栅化等,需要将顶点组装成几何图形。Pipeline将处理过的顶点连接成为一些最基本的图元,包括点、线和三角形等。这个过程成为图元装配(primitive  assembly)。

任何一个曲面都是多个平面无限逼近的,而最基本的是三点表示一个平面。所以,理论上,GPU将曲面都划分为若干个三角形,也就是使用三角形进行装配。但是也不排除现代GPU的设计者们使用其他的更有效的图元,比如梯形,进行装配。

(3)光栅化

我们前文提到,图形是使用像素阵列来表示的。所以,图元最终要转化为像素阵列,这个过程称为光栅化(rasterization),我们可以把光栅理解为像素的阵列。经过光栅化之后,图元被分解为一些片断(fragment),每个片段对应一个像素,其中有位置值(像素位置)、颜色、纹理坐标和深度等属性。

(4)片段处理

在Pipeline更新帧缓冲之前,Pipeline执行最后一系列的针对每个片段的操作。对于每一个片断,首先进行相关的测试,比如深度测试、模板测试。以深度测试为例,只有当片段的深度值小于深度缓存中与片段相对应的像素的深度值时,颜色缓冲、深度缓冲中的与片段相对应的像素的值才会被这个片段中对应的信息更新。

Pipeline可全部由软件实现(CPU),也可全部由硬件实现(GPU),或者二者混合,这完全取决于GPU的能力。对于GPU没有3D计算能力的,则Pipeline完全由软件实现。比如,Mesa中的_tnl_default_pipeline,即是一个纯软件的Pipeline,Pipeline中的每一个阶段均由CPU负责渲染:



对于3D计算能力比较强的GPU,如ATI的GPU,Pipeline完全由GPU实现。

而有些GPU能力不那么强大,那么CPU就要参与图形渲染了,因此,Pipeline一部分由CPU实现,一部分由GPU实现,比如基于Intel  i915  GPU的Pipeline:



相比于_tnl_default_pipeline,intel_pipeline使用_intel_render_stage替换了_tnl_render_stage。

以Intel  GPU为例,Pipeline的渲染过程大致如图8-8所示。

图 8-8 intel  GPU  3D渲染过程

1)首先,应用程序通过glVertex等OpenGL  API将数据写入用户空间的顶点缓冲。

2)当程序显示调用glFlush,或者,当顶点缓冲满时,其将自动激活glFlush,glFlush将启动Pipeline。以intel_pipeline为例,Pipeline的前几个阶段是CPU负责的,因此,所有的输入来自用户空间的顶点缓冲,计算结果也输出到用户空间的顶点缓冲;在最后的_intel_render_stage阶段,按照intel  GPU的要求,从公共的顶点缓冲中读取数据,使用intel  GPU的3D驱动中提供的函数,重新组织一个符合intel  GPU规范的顶点缓冲。

3)glFlush调用3D驱动中的函数intel_glFlush。intel_glFlush首先将顶点缓冲和批量缓冲复制到内核空间对应的BO,实际上就是相当于复制到了GPU的显存空间,这样GPU就可以访问了。然后,内核的DRM模块将按照Intel  GPU的要求建立一个环形缓冲区(ring  buffer)。

4)准备好环形缓冲区后,内核中的DRM模块将环形缓冲区的信息,如缓冲区的头和尾的地址分别写入GPU的寄存器Head  Offset和Tail  Offset等。当DRM向寄存器Tail  Offset写入数据时,将触发GPU读取并执行环形缓冲区中的命令,启动GPU中的Pipeline进行渲染。最后,GPU的Pipepline将生成的像素阵列输入到帧缓冲。

1.建立数学模型

使用OpenGL绘制,首先需要将绘制的内容使用数学模型描述出来,这个描述的过程的最终结果将保存在顶点缓冲中。我们以函数glVertex3f为例,来简单看看这个过程。

因为可能存在多个上下文,比如某个上下文使用的是软件渲染,另外一个上下文使用的是硬件渲染,因此,Mesa采用分发函数表(dispatch  table)实现访问当前上下文的GL函数。

GL上下文中有一个指向结构体_glapi_table的指针Exec,用于指向当前上下文的分发函数表,具体代码如下:



函数表作为GL上下文的一部分,在创建上下文时进行初始化,具体代码如下:



其中,函数_mesa_initialize_context创建了函数表,并初始化了函数表中的部分GL函数,如glFlush。函数glVertex3f是在初始化VBO时初始化的:



在函数vbo_exec_vtxfmt_init中,函数指针Vertex3f指向的函数最后会被mesa_install_exec_vtxfmt安装到函数表中,对应函数glVertex3f。

我们先来看看函数vbo_Vertex3f的实现:



根据宏TAG的定义,显然,TAG(Vertex3f)就是vbo_Vertex3f的函数实现。其中宏ATTR3F的定义如下:



根据宏ATTR的定义可见,vbo_Vertex3f就是将数学模型的相关数据写入顶点缓冲。

了解了函数vbo_Vertex3f的实现后,我们看看_mesa_install_exec_vtxfmt是如何将其安装到函数表的:



函数SET_Vertex3f的相关代码如下:



因为宏_gloffset_Vertex3f的定义为136,所以宏SET_by_offset设置函数表中第136项的函数指针指向函数vbo_Vertex3f。我们来看看GL函数表中的第136项的函数指针:



我们看到函数表中的第136项是Vertex3f,而不是glVertex3f,是不是很困惑?

事实上,由于采用这种跳转函数表的方式,给GL函数调用带来许多不必要的开销,因此,Mesa进行了必要的优化。比如,在IA32平台上,Mesa使用汇编语言实现OpenGL  API规定的这些函数。相比使用C语言,使用汇编语言实现的函数编译后的机器指令要更精简一些,相关代码如下:



因为要处理多种情况,再加上一些额外的汇编伪指令,所以代码比较复杂,为了增加可读性,笔者进行了必要的删减。

从第1行代码处开始,Mesa使用宏GL_STUB开始定义OpenGL  API规定的函数,其中第3行代码定义的就是函数glVertex3f。

注意定义函数使用的宏GL_STUB,其在第6~10行代码定义。其中第7行代码定义的是函数名,代码中宏GL_PREFIX在第12行代码定义,就是给函数名称前加个前缀gl,所以



展开后为:



可见,第3行代码使用宏GL_STUB定义的就是函数glVertex3f。

我们再来看看宏GL_STUB定义的函数体。第9行代码获取函数表所在的基址,然后跳转到偏移off处,见第10行代码。以函数glVertex3f为例,根据第3行代码可见,这个偏移是136。也就是说,当程序执行函数glVertex3f时,其将跳转到函数表中第136项指针指向的函数。

而前面函数SET_Vertex3f正是将函数vbo_Vertex3f安装到了函数表的第136项。也就是说,当执行函数glVertex3f时,实际跳转到的函数就是vbo_Vertex3f。

2.启动Pipeline

在建模后,应用将顶点数据存入了顶点缓冲,加工需要的原材料已经准备好了,接下来就需要开动Pipeline这台加工机器了。那么,这个机器什么时候运转起来呢?通常是在程序中显示调用函数glFlush时。当然,一旦顶点缓冲已经充满了,也会自动调用glFlush。读者可能有个疑问:我们编写程序时,有时并没有显示调用glFlush啊?没错,那是通常情况下,我们使用的都是启用了双缓冲的OpenGL,即前缓冲和后缓冲。对于启用双缓冲的OpenGL程序,OpenGL规定,当程序在后缓冲渲染完成后,请求交换到前后缓冲时,使用OpenGL的API  glXSwapBuffers,而实际上,函数glXSwapBuffers已经替我们调用了glFlush。

当调用函数glFlush时,将通过函数表跳转到函数_mesa_flush:



函数_mesa_flush首先使用宏FLUSH_CURRENT启动CPU负责的Pipeline。在CPU负责的Pipeline运行完毕后,_mesa_flush调用驱动中的Flush函数将用户空间的顶点缓冲、批量缓冲的数据复制到内核空间,并启动GPU中的Pipeline。

宏FLUSH_CURRENT调用函数_tnl_draw_prims开动Pipeline,具体代码如下:



我们看到,对于每个绘制原语,函数_tnl_draw_prims分别启动Pipeline对其进行加工。对于Intel  GPU的3D驱动,RunPipeline指向的函数是intelRunPipeline:



函数_tnl_run_pipeline依次运行Pipeline中每个阶段的run函数,一旦某个阶段的函数run返回False,则表明整个Pipeline运行结束。

3.Pipeline中的软件计算阶段

所谓的软件计算阶段,是指计算过程是由CPU来负责的。CPU从上下文中获取上个阶段的状态信息,进行计算,然后将计算结果保存到上下文中,作为下一个阶段的输入。上下文的数据抽象为结构体TNLcontext,其中非常重要的一个成员是结构体vertex_buffer:



顾名思义,结构体vertex_buffer是保存顶点数据的。软件计算阶段的所有顶点数据来自这个vertex_buffer,经过变换后的顶点数据也输出到这个vertex_buffer中。

以intel_pipeline中的texgen阶段为例:



第9行代码计算纹理的坐标,并将结果保存到store的数组texcoord中。而在函数TexgenFunc的计算过程中,使用了来自TNLcontext中的结构体vertex_buffer中的各种状态信息。

计算完成后,函数run_texgen_stage也将这个阶段的计算结果保存到了TNLcontext中的结构体vertex_buffer中,如代码第11~12行所示。

4.Pipeline中GPU相关的阶段

很难要求所有厂家的GPU都按照一个标准设计,所以在启动GPU中的硬件阶段之前,需要将OpenGL标准规定的标准格式的顶点缓冲中的数据按照具体的GPU的要求组织一下,然后再传递给GPU。下面我们就以Intel  i915系列GPU的Pipeline中的_intel_render_stage为例,看看其是如何为GPU准备批量缓冲的。

前面我们在函数_tnl_run_pipeline中看到,Pipeline在运行时,是依次调用各个阶段的run函数来运行各个阶段的。_intel_render_stage阶段的run函数是intel_run_render:



其中,代码第4~11行的for循环,将依次调用特定GPU相关的函数按照GPU要求的格式重新组织顶点缓冲。以Intel  GPU的3D驱动为例,其另外分配了与驱动相关的顶点缓冲存储重新组织顶点数据:



在结构体prim中,vb指向的是用户空间的顶点缓冲,vb_bo指向的是内核空间创建的保存顶点数据的BO。

intel  i915系列GPU的3D驱动中组织三角形的顶点缓冲的函数为intel_draw_triangle:



函数intel_draw_triangle使用宏COPY_DWORDS向顶点缓冲中指定偏移处写入顶点数据。对于每一个三角形来说都包括三个顶点数据,因此调用三次宏COPY_DWORDS,将三角形的三个顶点写入了顶点缓冲。

处理完顶点缓冲后,函数intel_run_render就将开始为GPU组织批量缓冲。Intel  GPU的3D驱动中批量缓冲的数据抽象如下:



在结构体intel_batchbuffer中,数组map就是用户空间中的批量缓冲,bo指向的就是内核空间中的保存批量数据的BO。可见,3D驱动中使用批量缓冲的方式与2D驱动中的基本相同。

函数intel_run_render在最后调用了宏INTEL_FIREVERTICES,开启了批量缓冲的生成过程:



函数指针flush指向函数intel_flush_prim:



这里,我们再次看到与2D驱动中类似的宏定义(如OUT_BATCH等),它们基本与2D驱动中的定义完全相同,我们不再展开分析这些宏定义了。在上述组织批量缓冲的代码片段中:

1)第6行代码在批量缓冲中填充了发给GPU的3D命令的指令代码(opcode);

2)第8行代码在批量缓冲中填充了引用的保存顶点数据的BO;

3)第12~16行代码在批量缓冲中填写了渲染原语的相关信息,比如绘制的是三角形还是线段等;

4)第17行代码指明了绘制这个原语需要的顶点数据在保存顶点数据的BO中的偏移。

至此,用户空间中的批量缓冲也准备好了。下一步,就是将用户空间的数据复制到内核空间的BO,并启动GPU中的Pipeline。

5.复制顶点数据和批量数据到内核空间

在Pipeline的软件阶段,所有阶段的计算结果都保存在用户空间,为了启动Pipeline的硬件阶段,显然需要将这些数据复制到内核空间的BO,这样GPU才可以访问。_mesa_flush最后将调用3D驱动中的函数_intel_batchbuffer_flush进行复制:



我们先来看一下函数finish_batch。对于i915来说,其指向的函数是intel_finish_vb:



函数drm_intel_bo_subdata我们已经见过了,其将用户空间的顶点缓冲中的数据复制到内核空间中保存顶点数据的BO。

接下来,再来看函数_intel_batchbuffer_flush中调用的do_flush_locked:



函数do_flush_locked首先调用drm_intel_bo_subdata将用户空间的批量缓冲中的数据复制到内核空间中保存批量数据的BO。至此,用户空间的顶点缓冲和批量缓冲中的数据都被复制到内核空间的BO。

在将用户空间的数据复制到内核空间中的BO后,do_flush_locked调用库libdrm中的函数drm_intel_bo_mrb_exec通知GPU启动其Pipeline开始渲染,这个过程我们下一节讨论。

6.启动GPU中的Pipeline

将数据复制到内核空间的BO后,接下来就需要通知GPU来读取这些数据,并执行GPU中的Pipeline。以Intel  GPU为例,其规定需要将批量数据组织到一个环形缓冲区中,然后GPU从环形缓冲区中读取并执行命令,如图8-9所示。

图 8-9 GPU命令流

环形缓冲区也只是从内存中分配的一块用于显存的普通存储区,所以,当内核中的DRM模块组织好其中的数据后,GPU并不会自动到环形缓冲区中读取数据,而是需要通知GPU来读取。

那么内核如何通知GPU呢?熟悉驱动开发的读者应该比较容易猜到,方法之一就是直接写GPU的寄存器。Intel  GPU为环形缓冲区设计了专门的寄存器,典型的包括Head  Offset、Tail  Offset等。其中寄存器Head  Offset中记录环形缓存区中有效数据的起始位置,寄存器Tail  Offset中记录的则是环形缓存区中有效数据的结束位置。

一旦内核中的DRM模块向寄存器Tail  Offset中写入数据,GPU就将对比寄存器Head  Offset和Tail  Offset中的值。如果这两个寄存器中的值不相等,那么就说明环形缓冲区中已经存在有效的命令了,GPU中的命令解析单元(Command  Parser)通过DMA的方式直接从环形缓冲区中读取命令,并根据命令的类型,定向给不同的处理引擎。如果是3D命令,则转发给GPU中的3D引擎;如果是2D命令,则转发给GPU中的BLT引擎;如果是控制显示的,则转发给Display引擎;等等。

理解了相关原理后,下面我们就来看看DRM中具体的实现。在函数drm_intel_bo_subdata将数据复制到内核空间的BO后,do_flush_locked调用了函数drm_intel_bo_mrb_exec向内核DRM模块发送命令DRM_IOCTL_I915_GEM_EXECBUFFER或者DRM_IOCTL_I915_GEM_EXECBUFFER2(依据GPU的具体情况)。以DRM模块中处理命令DRM_IOCTL_I915_GEM_EXECBUFFER2的函数i915_gem_execbuffer2为例,组织并启动GPU读取环形缓冲区的相关代码如下:



注意函数i915_dispatch_execbuffer中的函数intel_ring_emit,读者一定想到了组织批量缓冲的宏OUT_BATCH的定义,没错,这里就是在填充环形缓冲区。

在组织好环形缓冲后,i915_dispatch_execbuffer调用了函数intel_ring_advance扣动了GPU的扳机,相关代码如下:



以i915系列为例,GPU的相应寄存器在CPU地址空间中占据的地址如下:



根据Intel的GPU的手册,地址"0x02000+0x30"恰恰就是GPU的寄存器Tail  Offset在CPU的地址空间中分配的地址。

根据上述分析可见,内核的DRM模块通过写GPU的寄存器Tail  Offset启动了GPU中的Pipeline。最后Pipeline会将生成的图像的像素阵列输出到后缓冲的BO。



8.4.3 交换前缓冲和后缓冲

应用程序绘制完成后,需要将后缓冲交换(swap)到前缓冲,其中有三个问题需要考虑。

(1)谁来负责交换

如果应用自己负责将后缓冲更新到前缓冲,那么当有多个应用同时更新前缓冲时如何协调?显然将交换动作交给更擅长窗口管理的X服务器统一协调更为合理。

如果X服务器开启了复合扩展,更需要知道应用已经更新前缓冲了,因为X服务器需要通知复合管理器重新合成前缓冲。

综上,应该由X服务器来负责交换前后缓冲。

对于GPU支持交换的情况,X服务器通过2D驱动请求GPU进行交换。否则X服务器只能将前缓冲和后缓冲的BO映射到用户空间,使用CPU逐位复制。

(2)交换的时机

与2D应用不同,3D程序通常涉及复杂的动画和图像,如果显示控制器正在扫描前缓冲的同时,X服务器更新了前缓冲,那么可能会导致屏幕出现撕裂(tearing)现象。所谓的撕裂就是指本应该分为两桢显示在屏幕上的图像同时显示在屏幕上,上半部分是一帧的上半部分,而下半部分是另外一帧的下半部分,情况严重的将导致屏幕出现闪烁(flicker)。

以一个刷新率为60Hz的显示器为例,显示控制器每隔1/60秒从前缓冲读取数据传给显示器。每开始新的一帧扫描时,显示控制器都从前缓冲的最左上角的点,即第一行的第一个点开始,逐行进行扫描,直到扫描到图像右下角的点,即最后一行的最后一个点。经过这样一个过程之后,就完成了一帧图像的扫描。然后显示控制器回溯(retrace)到第一行的第一个点的位置,等待下一帧扫描开始,如图8-10所示。

图 8-10 图像扫描示意图

更新一帧图像远不需要1/60秒,从更新完最后一行的最右侧一个点,到开始扫描下一帧之间的间隙被称为垂直空闲(vertical  blank),简称为"vblank"。显然,如果在vblank这段时间更新前缓冲,就不会导致上述撕裂和闪烁现象的出现了。

(3)交换的方法

交换后缓冲和前缓冲通常有两种方法:第一是复制,在绘制完成后,X服务器将后缓冲中的数据复制到前缓冲,如图8-11所示。

图 8-11 复制模式

但是这种方法效率相对较低,所以开发者们设计了页翻转模式(page  flip)。页翻转模式不进行数据复制,而是将显示控制器指向后缓冲。后缓冲与前缓冲的角色进行互换,后缓冲摇身一变成为前缓冲,显示控制器将扫描后缓冲的数据到屏幕,而原来的前缓冲则变成了后缓冲,应用程序在前缓冲上进行绘制,如图8-12所示。

图 8-12 页翻转模式

页翻转模式虽然效率高,但也不是所有的情况都适用。典型的,当一个应用处于全屏模式时,可以采用页翻转模式互换前缓冲和后缓冲。但是这对于使用复合管理器的图形系统来说,其实已经大大的提升效率了,因为复合管理器控制着整个屏幕的显示,所以复合管理器可以使用页翻转模式交换前缓冲和后缓冲。

1.应用发送交换请求

对于一个OpenGL程序来说,在绘制完成后,需要调用GLX扩展中的函数glXSwapBuffers向X服务器发出交换请求:



glXSwapBuffers首先调用glFlush启动Pipeline进行渲染。然后调用DRI2扩展的指针swapBuffers指向的函数向X服务器发出交换请求。DRI2扩展中指针swapBuffers指向的函数是DRI2SwapBuffers:



函数DRI2SwapBuffers创建了一个类型为X_DRI2SwapBuffers的X请求,然后调用函数_XReply将这个请求发送给X服务器。

2.X服务器处理交换请求

X服务器中处理来自OpenGL应用的请求在DRI/GLX的扩展模块中,对应的函数是DRI2SwapBuffers:



函数DRI2SwapBuffers首先获取请求更新的窗口的前缓冲和后缓冲。X服务器在前面创建帧缓冲时已经将各个缓冲记录到了各个窗口中,所以这里取出即可。其中,pDestBuffer指向前缓冲,pSrcBuffer指向后缓冲。取得前缓冲和后缓冲后,具体的交换动作显然需要2D驱动来完成。DRI2SwapBuffers调用2D驱动中的函数ScheduleSwap交换后缓冲和前缓冲。

在Intel  GPU的2D驱动中,函数指针ScheduleSwap指向函数I830DRI2ScheduleSwap:



前面谈到X服务器应该在vblank时更新前缓冲,实现中也确实如此。I830DRI2ScheduleSwap没有直接进行交换,而是调用库libdrm中的函数drmWaitVBlank,这个函数告诉显示控制器,在vblank时,向内核发送vblank事件,如第17行代码所示。

函数I830DRI2ScheduleSwap需要做的另外一件事就是判断前缓冲和后缓冲的交换方式。默认的交换方式是复制,如第7行代码所示。第9~12行代码调用函数can_exchange来判断是否可以使用更高效的页翻转方式。

Intel  GPU的2D驱动在初始化时注册vblank事件的回调函数是intel_vblank_handler:



收到vblank事件后,函数I830DRI2FrameEventHandler首先判断等待vblank的交换请求希望使用的是页翻转模式还是复制模式。如果是页翻转模式,为了安全起见,再次使用函数can_exchange检查是否可以进行页翻转,确认没有问题后,则调用函数I830DRI2ScheduleFlip执行翻转。否则,则调用函数I830DRI2CopyRegion将后缓冲的内容复制到前缓冲。

(1)页翻转模式

进行页翻转的函数I830DRI2ScheduleFlip的相关代码如下:



I830DRI2ScheduleFlip调用2D驱动中的函数intel_do_pageflip进行翻转。当然翻转后需要更新状态,包括更新当Screen  Pixmap对应的BO,这就是函数I830DRI2ScheduleFlip调用I830DRI2ExchangeBuffers的目的。2D驱动中函数intel_do_pageflip的代码如下:



函数intel_do_pageflip并没有使用库libdrm提供的接口,如drmModeSetCrtc设置显示控制器扫描的缓冲,而是使用了接口drmModePageFlip。相比于有点莽撞的drmModeSetCrtc,函数drmModePageFlip能确保是在发生vblank时设置显示控制器扫描的缓冲。drmModePageFlip将翻转的动作排队到下一个vblank事件发生时的处理队列中,在下个vblank发生时,设置显示控制器扫描的缓冲。

(2)复制模式

处理复制模式的函数I830DRI2CopyRegion的代码如下:



看到ops,读者一定非常熟悉了,没错,这就是我们前面讨论2D渲染时提及的画笔。在UXA(uxa_ops)中,CopyArea对应的函数是intel_uxa_copy:



看到函数intel_uxa_copy的内容是否似曾相识?没错,指令XY_SRC_COPY_BLT与8.3.2节讨论的指令XY_COLOR_BLT非常相似,最大的不同是多了复制的源的信息。Intel  GPU的指令XY_SRC_COPY_BLT的格式如表8-2所示。

下面我们结合表8-2来分析函数intel_uxa_copy为GPU组织批量缓冲的过程。

1)第9行代码填充的是第0个双字,即BLT引擎的寄存器BR00。这个寄存器中最重要的就是指令的操作码(Opcode),即第22~28位。对于指令XY_SRC_COPY_BLT,其操作码是0x53。观察宏XY_SRC_COPY_BLT_CMD的定义:



其中从第22位开始的0x53正是指令XY_SRC_COPY_BLT的指令码。另外,第29~30位设置为2,告诉GPU这个指令是一个2D指令,需要GPU定向给BLT引擎。

2)第11行代码填充的是第1个双字,对应BLT引擎的寄存器BR13,其中"intel->BR[13]"在8.3.2节我们已经讨论过,表示色深。另外,dst_pitch表示目标区域的跨度,所谓的跨度就是以字节为单位的图形的宽度。

3)第12行代码填充了第2个双字,对应BLT引擎的寄存器BR22,这个寄存器中保存的是目标区域的左上角的坐标。

4)第13行代码填充了第3个双字,对应BLT引擎的寄存器BR23,这个寄存器中保存的是目标区域的右下角的坐标。

5)第14行代码填充了第4个双字,对应BLT引擎的寄存器BR09,这个寄存器中保存的是存储目标区域像素阵列的BO,当然使用的是BO在GPU虚拟地址空间的地址,即BO的offset。

6)第15行代码填充了第5个双字,对应BLT引擎的寄存器BR26,这个寄存器中保存的是源区域的左上角的坐标。

7)第16行代码填充了第6个双字,对应BLT引擎的寄存器BR11,这个寄存器中保存的是源区域的图形的跨度。

8)第17行代码填充了第7个双字,对应BLT引擎