深度探索Linux操作系统 —— Linux图形原理探讨1:https://developer.aliyun.com/article/1598095
三、2D渲染
这一节,我们结合 X 窗口系统,讨论 2D 程序的渲染过程。我们可以形象地将 2D 渲染过程比喻为绘画,其中有两个关键的地方:一个是画布,另外一个是画笔。
X 服务器启动后,将加载 GPU 的 2D 驱动,2D 驱动将请求内核中的 DRM 模块创建帧缓冲,这个帧缓冲就相当于画布。然后 X 服务器按照绘画需要,从画笔盒子中挑选合适的画笔进行绘画。
X 的画笔保存在结构体 GCOps 中,其中包含了基本的绘制操作,如绘制矩形的 PolyRectangle ,绘制圆弧的 PolyArc ,绘制实心多边形的 FillPolygon ,等等。代码如下:
// xorg-server-1.12.2/include/gcstruct.h: typedef struct _GCOps { ... void (*PolyRectangle) (DrawablePtr /*pDrawable */ , ...); void (*PolyArc) (DrawablePtr /*pDrawable */ , ...); void (*FillPolygon) (DrawablePtr /*pDrawable */ , ...); void (*PolyFillRect) (DrawablePtr /*pDrawable */ , ...); ... } GCOps;
最初,这些绘制操作均由 CPU 负责完成,也就是我们通常所说的软件渲染。X 中的 fb 层就是软件渲染的实现,代码如下:
// xorg-server-1.12.2/fb/fbgc.c: const GCOps fbGCOps = { fbFillSpans, ... fbPolySegment, fbPolyRectangle, fbPolyArc, miFillPolygon, ... };
但是随着 GPU 的不断发展,其计算能力越来越强。于是 X 的开发者们不断改进 X 的渲染部分,希望能充分利用 GPU 擅长的图形操作以大幅提高计算机的图形能力,而又可以解放 CPU,使其专心于控制逻辑。也就是说,X 的开发者们希望画笔盒子中的画笔更多地来自 GPU 。
当然,任何事物都不是一蹴而就的,GPU 的渲染能力也是螺旋演进的,对于 GPU 尚未实现的或者相比来说 CPU 更适合的渲染操作还是需要 CPU 来完成,因此,X 的渲染架构也随着 GPU 的演进不断地改进。在 XFree86 3.3 的时候,X 的开发者设计了 XAA(XFree86 Acceleration Architecture)架构;在 X.Org Server 6.9 版本,开发者用改进的 EXA 取代了 XAA;当 DRM 中使用了 GEM 后,Intel 的 GPU 驱动开发者们重新实现了 EXA ,并命名为 UXA(Unified Acceleration Architecture);随着 Intel 推出 Sandy Bridge 及 ivy Birdge 芯片组,Intel 又开发了 SNA(SandyBridge’s New Acceleration)。
后续,我们以成熟且稳定的 UXA 为例进行讨论。在 UXA 架构下,X 的画笔盒子如下:
// xf86-video-intel-2.19.0/uxa/uxa-accel.c: const GCOps uxa_ops = { uxa_fill_spans, ... uxa_poly_lines, uxa_poly_segment, miPolyRectangle, uxa_check_poly_arc, miFillPolygon, ... };
我们看到 uxa_ops 包含在 Intel 的 GPU 驱动中,当然,这是非常合理的,因为只有 GPU 自己最清楚哪些渲染自己可以胜任,哪些还需要 CPU 来负责。在 uxa_ops 中,有一部分画笔来自 GPU ,另外一部分来自 CPU 。
对于每一个绘制操作,UXA 首先检查 GPU 是否支持这个绘制操作,或者说在某些条件下,对于这个绘制操作,GPU 渲染的比 CPU 更快。如果 GPU 支持这个绘制操作,UXA 首先将绘制的命令翻译为 GPU 可以识别的指令,并将这个指令、绘制所需的相关数据,以及保存像素阵列的 BO 在显存地址空间中的地址,一同保存在用户空间的批量缓冲(Batch Buffer),然后通过 DRM 将用户空间的批量缓冲复制到内核为批量缓冲创建的 BO ,之后通知 GPU 从 BO 中读取指令和数据进行绘制。实际上,DRM 按照 Intel GPU 的要求在批量缓冲和 GPU 之间还组织了一个环形缓冲区(Ring buffer),但是我们暂时忽略它,这对于理解 2D 渲染过程没有任何影响,后面在讨论 3D 渲染过程时,我们会简单的讨论这个环形缓冲区。
如果 GPU 不支持这个绘制操作,那么 UXA 将代表帧缓冲的 BO 映射到 X 服务器的用户空间,X 服务器借助 fb 层中的实现,使用 CPU 进行绘制。
也就是说,UXA 在 fb 和 GPU 加速的上面封装了一层,其根据具体绘制动作选择使用来自 GPU 的画笔或来自 CPU 的画笔。
综上,X 的 2D 渲染过程如图 8-5 所示。
不知读者是否注意到,无论是 fbGCOps,还是 uxa_ops,其中均有个别的绘制函数以 “mi” 开头。这些以 “mi” 开头的函数包含在 X 的 mi 层中。mi 是 Machine Independent 的缩写,顾名思义,是与机器无关的实现。笔者没有找到 X 中关于这个层的非常明确的解释,但是根据 mi 中的代码来看,其中的绘致函数根据不同的绘制条件,被拆分为调用其他 GCOps 中的绘制函数。
基本上,拆分的原因无外乎 GPU 支持的绘制原语有限,所以有些绘制操作需要分解为 GPU 可以支持的动作。或者出于绘制效率的考虑,将某些绘制操作拆分为效率更好的绘制原语。因此,X 将这些与具体绘制实现无关的代码剥离到
一个单独的模块 mi 中。从这个角度或许能解释 X 为什么将这个层命名为 Machine Independent。
1、创建前缓冲
在 X 环境下,在不开启复合(Composite)扩展的情况下,所有程序共享一个前缓冲。对于 2D 程序,所有的绘制动作生成的图像的像素阵列最终都输出到这个前缓冲上,窗口只不过是前缓冲中的一块区域而已。
但是一旦开启了复合扩展,那么每个窗口都将被分配一个离屏(offscreen)的缓冲,类似于 OpenGL 环境中的后缓冲。应用将生成的像素阵列输出到这个离屏的缓冲中,在绘制完成后,X 服务器将向复合管理器(Composite Manager)发送 Damage 事件,复合管理器收到这个事件后,将离屏缓冲区的内容合成到前缓冲。为了避免复合扩展干扰我们探讨图形渲染的本质,在讨论 2D、包括后面的 3D 渲染时,我们都不考虑复合扩展开启的情况。
在 X 中,Window 和 Pixmap 是两个绘制发生的地方,Window 代表屏幕上的窗口,Pixmap 则代表离屏的一个存储区域。所以自然而然的,X 使用数据结构 Pixmap 来表示前缓冲。因为这个前缓冲对应整个屏幕,而且不属于某一个应用,因此开发者也将代表前缓冲的这个 Pixmap 称为 Screen Pixmap 。后续为了行文方便,我们有时也使用 Screen Pixmap 这个词来代表前缓冲的这个 Pixmap 对象。显然,这个Screen Pixmap 也是显示器(Screen)的资源,所以X将其保存到了代表显示器的结构体_Screen中。
2、GPU渲染
GPU 渲染,也就是我们通常所说的硬件加速,从软件的层面所做的工作就是将数学模型按照 GPU 的规定,翻译为 GPU 可以识别的指令和数据,传递给 GPU,生成像素阵列等图像密集型计算则由 GPU 负责完成。可见,当使用 GPU 进行渲染时,在软件层面,实质上就是组织命令和数据而已。
Intel GPU 的 2D 驱动是如何将这些命令和数据传递给 GPU 的呢?读者一定想到了 BO 。在 Intel GPU 的 2D 驱动中,定义了使用了一种所谓的批量缓冲来保存这些命令和数据,这里所谓的批量就是将驱动准备命令和数据放到这个缓冲,然后批量地让 GPU 来读取,这就是批量缓冲的由来。
3、CPU渲染
根据上节讨论的函数 uxa_poly_fill_rect ,我们看到,GPU 并不是接收全部的绘制实心矩形的操作。对于不满足GPU条件的实心矩形,则将求助于 CPU 绘制,对应的函数是 uxa_check_poly_fill_rect 。
BO 是由 DRM 模块在内核空间分配的,因此运行在用户空间的 X( 2D驱动)要想访问这个内存,必须首先要将其映射到用户空间,这是由函数 uxa_prepare_access 来完成的。然后,X 使用 CPU 在映射到用户空间的 BO 上进行绘制。看到以 fb 开头的函数 fbPolyFillRect,读者一定猜到了,这就是 X 的 fb 层的函数,而 fb 层正是软件渲染的实现。
(1)映射 BO 到用户空间
函数 uxa_check_poly_fill_rect 调用 uxa_prepare_access 将 BO 映射到用户空间:
函数 intel_uxa_prepare_access 通过 libdrm 库中的函数 drm_intel_gem_bo_map_gtt 申请内核中的 DRM 模块将保存前缓冲的像素阵列的 BO 映射到用户空间:
看到熟悉的函数 mmap ,读者应该一切都明白了。从 CPU 的角度看,BO 与普通内存并无区别,所以,映射 BO 与映射普通内存完全相同。其中 bufmgr_gem->fd 指向的就是代表 BO 的共享内存。
(2)使用 CPU 在映射到用户空间的 BO 上进行绘制
X 的软件渲染层(即 fb 这一层),或者借助库 pixman 中的 API,或者自己直接操作像素数组,完成图形的绘制。其原理非常简单,就是直接设置像素数组中的颜色值或索引。
经过对 2D 渲染的探讨,我们看到,所谓的软件渲染和硬件加速,本质上都是生成图像的像素阵列,只不过一个是由 CPU 来计算的,另外一个是由 GPU 来计算的。当然,对于硬件加速,CPU 要充当一个翻译,将数学模型按照 GPU 的要求翻译为其可以识别的指令和数据。
四、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 所示。
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 程序的渲染过程。
1、创建帧缓冲
在 2D 渲染中,渲染过程都由 X 服务器完成,所以毫无争议,前缓冲由而且只能由 X 服务器创建。但是对于 DRI 程序来说,其渲染是在应用中完成,应用当然需要知道帧缓冲,但是 X 服务器控制着窗口的显示,所以 X 服务器也需要知道帧缓冲。所以,帧缓冲或者由 X 服务器创建,然后告知应用;或者由应用创建,然后再告知 X 服务器。X 采用的是前者。
虽然 OpenGL 中的帧缓冲的概念与 2D 相比有些不同,但本质上并无差别,帧缓冲中的每个缓冲都对应着一个 BO 。为了管理方便,Mesa 为帧缓冲以及其中的各个缓冲分别抽象了相应的数据结构,代码如下:
// Mesa-8.0.3/src/mesa/main/mtypes.h struct gl_framebuffer { ... struct gl_renderbuffer_attachment Attachment[BUFFER_COUNT]; ... };
其中,结构体 gl_framebuffer 是帧缓冲的抽象。结构体 gl_renderbuffer 是颜色缓冲、深度缓冲等的抽象。gl_framebuffer 中的数组 Attachment 中保存的就是颜色缓冲、深度缓冲等。
在具体的 3D 驱动中,通常会以 gl_renderbuffer 作为基类,派生出自己的类。如对于 Intel GPU 的 3D 驱动,派生的数据结构为 intel_renderbuffer :
// Mesa-8.0.3/src/mesa/drivers/dri/intel/intel_fbo.h struct intel_renderbuffer { struct swrast_renderbuffer Base; struct intel_mipmap_tree *mt; /** < The renderbuffer storage. */ };
其中指针 mt 间接指向缓冲区对应的 BO 。
如同在 Intel GPU 的 2D 驱动中,使用结构体 intel_pixmap 封装了 BO 一样,Intel GPU的 3D 驱动也在 BO 之上包装了一层 intel_region 。intel_region 中除了包括 BO 外,还包括缓冲区的一些信息,如缓冲区的宽度、高度等:
// Mesa-8.0.3/src/mesa/drivers/dri/intel/intel_regions.h: struct intel_region { drm_intel_bo *bo; /**< buffer manager's buffer */ GLuint refcount; /**< Reference count for region */ GLuint cpp; /**< bytes per pixel */ GLuint width; /**< in pixels */ ... };
当 OpenGL 应用调用 glXMakeCurrent 时,就开启了创建帧缓冲的过程,这个过程可分为三个阶段:
1)OpenGL 应用向 X 服务器请求为指定窗口创建帧缓冲对应的 BO 。帧缓冲中包含多个缓冲,所以当然是创建多个 BO 了。
2)X 服务器收到应用的请求后,为各个缓冲创建 BO 。在创建完成后,将 BO 的名字等相关信息发送给应用。
3)应用收到 BO 信息后,将更新 GPU 的状态。比如告诉 GPU 画板在哪里。
深度探索Linux操作系统 —— Linux图形原理探讨3:https://developer.aliyun.com/article/1598097