原创
2016-08-12
翻译:陈敬凤
Gad-腾讯游戏开发者平台
这篇文章是系列教程“图形渲染管线之旅”的一部分。
作者一共用了十一篇文章来仔细说明了硬件的图形渲染管线,本文是系列教程的第一部分。虽然作者写这个系列文章的时候是2011年,但是图形管线的基本架构这些年并没有发生太大的变化,考虑到这一方面分析深入的文章非常匮乏,大部分文章都是对图形学的渲染管线进行分析,而没有结合具体的图形库(D3D、OpenGL)以及硬件实现进行说明,所以尽管过去5年了,仍是不可多得的好文章。
我在这个博客上写一些技术方面的文章已经有一段时间了,我想我可能会在这个系列文章里面写一些东西来解释下2011年的图形硬件和软件的一些基本概念和知识点。你可以在你的个人电脑的显卡设备那里找到一些功能描述,但是通常情况下,对大多数人来说可能搞不清楚这些功能描述到底是什么以及为什么。我会尽量弥补这两者之间的空白,让你对图形渲染的底层有更多的了解,这样你可能知道那些描述到底是什么意思,但是我不会针对某些特定硬件进行讲解,而是侧重于介绍一些所有图形渲染管线的共同的特征。我主要讨论的是DX11级别的硬件,它们会在windows平台上运行着D3D9/10/11图形库,我选择这些硬件和图形库是因为这是我个人最熟悉的部分,但是请不要担心,我不会涉及一些具体的API(因为API是非常特定某个平台的,但是基础概念不是)。因为这是整个系列的第一部分,所以会讲到很多实际在GPU上执行的本地指令。
应用程序主要是指你的代码部分,里面还包括了各种bug。嗯,运行时API和驱动程序都会有bug,但我说的bug不是指的这两个部分的bug,而是你自己实现的代码里面的bug,现在准备好修复掉他们。
你可以对运行时API调用资源创建、状态设置,绘制调用等等功能。运行时API会记录你的应用程序设置的当前状态、验证参数、执行错误和一致性检测、管理用户的可见资源等功能,甚至还可能包括验证着色器代码以及进行着色器代码的链接(至少D3D是这样做的,在OpenGL里是在驱动程序级别进行处理的),还有可能执行一些合并批次,然后再把处理过的内容统统交给图形驱动程序,更准确一点来说,是用户模式图形驱动程序。
这是发生在CPU这一侧最为”神奇“的部分,如果你的应用程序因为调用了某些API崩溃的话,那通常都是这里的原因:)。用户模式图形驱动程序的名字可能是“nbd3dum.dll”(在NVidia平台上)或者“atiumd*.dll”(在AMD平台上)。 如命名所暗示的那样,这是运行在用户模式下的代码。它和你的应用程序运行在同一个上下文和地址空间里(API运行时也是运行在相同的上下文和地址空间里),并没有什么特权。它实现了一个底层API(DDI),通常被称为D3D。这个API和你表面上看到的很类似,但是在诸如内存管理这些地方上有一点区别。 这个模块主要负责一些诸如着色器编译之类的事情。D3D把一个预先校验通过的着色器符号流传递给用户模式图形驱动程序,所谓的预先校验通过是指从语法正确的角度已经检测过着色器代码的有效性了以及符合D3D的约束(使用了正常的类型、使用的纹理/采样器的数目没有超出可用的范围、使用的常量缓冲区的数目没有超出可用的范围等这些约束)。传入的着色器符号流是基于HLSL语言,会对这些HLSL语言进行编译,通常会对原始的字符流应用非常多的高级优化(各种循环优化、消除没用的代码、常量传递、预测分支等)。这是很棒的事情,在编译阶段进行了大量的优化,图形驱动程序在执行这些代码的时候就会节省大量的时间进而从中受益。但是,还有大量的底层优化(比如寄存器分配和循环展开)需要图形驱动程序自己来做。简单的来说,通常会立刻转化成用中间语言(intermediate representation,IR)来表示,然后再对中间语言进行编译。着色器的硬件指令很接近于D3D字节码,不需要花费特别大的精力和特别多的技巧就能很顺利的编译(HLSL编译器帮助做了许多高收益高成本的优化,这些优化确实非常有帮助),但是仍有一些底层细节(比如硬件资源限制和调度约束),D3D不知道也并不关心,所以这不是一个简单的过程。 当然,如果你所开发的应用程序是一个知名游戏的话,NV/AMD的程序员可能会查看你的着色器,然后在他们的硬件上做一些手工优化,尽管这么做来提高硬件的跑分和效率其实一种作弊和丑闻:)这些着色器也会被用户模式图形驱动程序检测并且进行替换。如果你在开发中遇到这种情况,请不用客气,尽情享受NV/AMD的程序员的帮助。更有趣的是:某些API状态可能实际上最终会被编译进着色器里——举个例子来说,一些相对怪异的或者至少是不常使用的特性,比如纹理边框(texture borders)可能没有被纹理采样器实现,而是通过额外的着色器代码来模拟实现的(或者干脆完全不支持)。这意味着,有时候同一段着色器代码会有多个不同版本的实现,主要是为了不同API状态的组合。 顺便说一下,这就是为什么你经常看到在第一次使用一个新的着色器时候会发生延迟。大量的创建/编译工作被驱动程序延迟到这个着色器实际用到的时候才执行(你根本不会相信一些应用程序创建了多少没用的资源)。图形程序员都知道这么一个事情——如果你想要确保某些东西被实际创建了(而不是仅仅在内存里保留了一个区域),你需要执行一个空的绘制调用来给它热热身(也就是因为进行了实际调用,驱动程序就会将原来延迟的创建/编译工作实际执行,进而实际创建出来,这样当你实际进行调用的时候,它们就已经创建好了,这样就不会出现延迟了)。这种做法很丑陋也很烦人,但是这自从我1999年第一次开始使用3D硬件整个情况就一直这样,现实生活就是这样,我们只好去适应它:)(实际上这是一种很好的优化策略,如果图形驱动程序真的在一开始的时候就分配好内存并且都进行初始化,一个是会耗费极大的资源,很有可能导致内存不够,导致更加频繁的崩溃,程序更加不稳定,另外一个问题这样做花费的时间可能会非常长,当玩家打开应用程序的时候,只能静静的等待,这等于玩家来说是个很不好的体验,而更恐怖的是,这两种情况可能混合在一起,因为显存不够或者其他一些问题,在初始化的时候会出现崩溃,玩家只能再次打开应用程序,然后等待了很长的时间,发现应用程序再次崩溃,这真的太恐怖了!相比之下,现在这种比较丑陋的做法还是比较安全的并且健壮的。) 无论如何,让我们继续原来的话题。用户模式图形驱动程序还能处理一些有趣的东西,比如D3D9里面保存着之前版本遗留的着色器版本和固定管线的代码——你没看错,这些东西当前版本的D3D9都百分百的支持。着色器 3.0的配置文件并没有那么糟糕(实际上它还相当合理),但是着色器 2.0的配置文件就有点混乱了,着色器 1.x的各种版本配置文件就非常非常糟糕了,还记得1.3版本的像素着色器么?或者对于这问题换个说法,当前版本的D3D9还支持带顶点光照的固定顶点管线么?是的,D3D和每一个现代显卡都仍然支持这些功能,尽管只是把这些古老的功能用新的着色器方法重新实现了一遍(这么做已经很长时间了)。
还有一些诸如内存管理的问题。用户模式图形驱动程序要获取像纹理创建指令这些内容,并且需要为他们分配空间。实际上用户模式图形驱动程序只是内核模式驱动程序(KMD,Kernel-Mode Driver)分配的一些大的内存块,实际的映射和非映射页(管理哪些显存对于用户模式图形驱动程序是可见的,这些显存也是GPU可以访问的系统内存)是内核模式驱动程序的特权,用户模式图形驱动程序不能做。
但是用户模式图形驱动程序可以做一些像混合纹理(swizzling textures,除非是GPU在硬件中做这个事情,通常都是使用2D块传输单元来实现这个功能而不是通过真正的3D管线来做这个)和系统内存和(映射的)显卡显存之间的传输调度,以及其他一些功能。最重要的是,一旦内核模式驱动程序分配和处理内存的话,用户模式图形驱动程序还可以将指令写入指令缓冲区(或叫”DMA 缓冲区“——我将交替使用这两个名字)。从名字上也能看出,指令缓冲区包含的是指令。所有的状态改变和绘制操作将通过用户模式图形驱动程序转化成硬件可识别指令。很多事情不需要手动触发——比如上传纹理和着色器到显卡显存。 一般来说,驱动程序会尽可能的把尽量多的实际处理放在用户模式图形驱动程序中来完成。因为用户模式图形驱动程序是运行在用户模式下的代码,所以运行在用户模式图形驱动程序的部分并不需要进行昂贵的内核模式转换,并且可以自由分配内存、把工作分派给多个线程来分开执行,还有一些其他类似的功能-它只是一个常规的DLL(尽管它是由你的API加载的,而不是直接由你的应用程序进行加载的)。这对于驱动开发来说也是有利的-如果用户模式图形驱动程序崩溃了,那么应用程序也会崩溃,但是整个系统还是正常的。当系统运行的时候,用户模式图形驱动程序可以实时发生替换(它仅仅是个DLL!)。它还可以被常规的调试器调试,其他常规DLL可以做的事情它也可以做。所以在用户模式图形驱动程序执行代码不仅很有效率,还非常的方便。
我说过UMD是“用户模式驱动程序”了么?它确实是一个“用户模式下的驱动程序”
如之前所说,用户模式图形驱动程序只是个DLL。好吧,它可以通过D3D的帮助直接与内核模式驱动程序通信,但它仍旧是个普通的DLL,运行在调用进程的地址空间中。 但是,我们现在使用的是多任务的操作系统。事实上,我们使用多任务的操作系统已经很久了。
我一直在谈论关于GPU到底是什么?它是一个共享资源。你的主显示器只有一个(即使你使用SLI/Crossfire)。然而,我们有多个应用程序尝试访问它(并且它们会装作只有它们一个程序在试图做这个事情)。很明显,我们需要做些什么才能解决这个问题。在过去,解决办法是一次只让一个应用程序来访问3D渲染管线,当这个应用程序处于激活状态的时候,其他的应用程序都不能访问3D渲染管线。但是如果你尝试让你的窗口系统使用GPU渲染,这就不行了。这就是为什么你需要一些组件来仲裁谁能访问GPU并给其分配时间片的原因。
这是一个系统组件。这里容易产生一些误解。我这里说的调度器是图形渲染管线的调度器,不是CPU或IO的调度器。它所负责的内容就是你认为它会做的那些事情--由它来决定不同的应用程序访问3D图形渲染管线的时间片。在出现不同的应用程序来交替使用3D图形渲染管线的时候,会发生上下文切换,最起码也要切换GPU的一些状态(会产生额外的指令到指令缓冲区)和并且还会交换一些显存中的资源。
你会经常发现主机程序员抱怨PC上的3D API太抽象、不可控并且耗性能。但是,比起主机而言,PC上的3D API和3D图形驱动程序真的有太多复杂问题要解决——举个例子来说,他们需要跟踪全部的当前状态,因为随时有可能在任何地方出现问题!他们还要支持各种各样的应用程序,尝试修复它们背后的性能问题。这是一件非常烦人的事,没人会喜欢,当然也包括驱动程序作者他们自己,但实际上站在商业角度上,人们想要的是应用能继续运行(并且顺利运行)。只是对着应用程序喊“出错啦!”,然后生着闷气,慢慢检查,你是交不到朋友的。(这个地方作者是对图形驱动)
让我们继续下去。渲染管线之旅的下一站:内核模式驱动程序!
这是由硬件进行处理的部分。可能在同一时间段有多个不同的用户模式图形驱动程序实例在运行,但内核模式驱动程序(KMD)永远只有一个,如果这个内核模式驱动程序(KMD)奔溃了,你的程序也完蛋了――以前操作系统的表现是蓝屏,但现在的Windows知道如何杀死驱动程序崩溃的进程并且重新载入它。尽管只是发生了驱动程序的崩溃,而非内核内存被损坏,但一旦发生,所有创建的资源就都没有了。 内核模式驱动程序一次性的处理所有事物,尽管多个应用程序都在争夺它的使用权,但GPU的内存只有一份。 某些程序需要进行控制来分配(和映射)实际的物理内存。同样,某些程序在启动时必须初始化GPU、设置显示模式(从显示设备获取信息)、管理硬件鼠标指针(是的,有硬件处理这些内容,并且,你是依次得到这些数据的!)、管理硬件的检测计时器(如果一定时间内无响应,就重置GPU)、响应中短短等等。这就是内核模式驱动程序需要做的事情还有视频播放器DRM格式的内容保护,以及GPU解码像素对用户模式下的代码来说不可见,这可能会导致一些糟糕的事情,比如转存储到磁盘。内核模式驱动程序也会参与到这些处理中来。对我们来说最重要的是,内核模式驱动程序管理实际的指令缓冲区。要知道,这才是硬件实际进行处理指令的地方。用户模式图形驱动程序管理的指令缓冲区并不是真实的——事实上,它们只是GPU可以寻址的随机内存片段。实际上,用户模式图形驱动程序会将指令统一放到指令缓冲区,然后将它们提交到调度器,然后就会等待被处理,等到实际处理的时候再把用户模式图形驱动程序的指令缓冲区提交到内核模式驱动程序中去。内核模式驱动程序然后把指令缓冲区的指令写入到它管理的主指令缓冲区,再跟据GPU指令处理器能否读取主内存来决定是否需要把指令通过DMA传递给显存中去。主指令缓冲区通常是一个(相当小的)环形缓冲区-只有系统/初始化指令才会写入到这里,这是真正的3D指令缓冲区。但这些事情还是只是发生在内存的缓冲区中。显卡还得需要知道这个缓冲区的位置,通常有一个读指针,负责记录GPU目前在主指令缓冲区的读取位置,还有一个写指针,记录内核模式驱动程序已写入缓冲的位置(或者更准确的说到告诉GPU目前为止写了多少内存)。这些指针都是硬件寄存器,并且做了内存映射。内核模式驱动程序会周期性更新它们(通常是在每次提交新的工作块的时候)……
但是CPU的写入不是直接写入到显卡上(除非是集成在CPU的显卡!),因为写入的执行首先需要通过总线-通常是PCI Express总线。DMA传输也是通过这种方式。这部分花费的时间不会很多,但是它确实是管线渲染的另外一个阶段了。直到最后。。。
这个组件位于GPU的前端-负责读取内核模式驱动程序写入的指令。我将在下一篇文章中继续讲解这个部分,因为这一篇已经足够长了。
OpenGL的内部机制和我上面描述的非常接近。除了API层和用户模式图形驱动程序并没有这么明显的区分。不像D3D,OpenGL的API层根本就不处理着色器的编译,统统都是由图形驱动程序处理的。但是不幸的是,这样会带来一个副作用,有多少GLSL前端就会有多少个3D硬件厂商自己的实现,虽然这些硬件厂商要实现同规格都是相同的,但是在实现里都会有自己的bug和特性。这个事情并不有趣。而且它还意味着当图形驱动程序看到着色器代码的时候它需要自己处理所有的优化,包括那些非常耗的优化。就这个问题来说,D3D字节码格式是一种更简洁的解决方案,只有一个编译器(所以不同厂商的硬件之间没有丝毫的不兼容!)而且它支持一些比你通常会做的更耗的优化和处理。
About these ads这仅仅是一个概述。在这篇文章里面略过去没有讲的细节有很多。举个例子来说,其实没并没有一个单独的调度程序,而是有多个实现(驱动程序可以选择一个来使用)。还有就是CPU和GPU之间到底是如何同步的整个细节我根本就没有做任何解释。还有很多其他类似的情况。我可能会忘记一些重要的事情 - 如果是这样,请告诉我,我会解决的!但是现在,到了结束这篇文章的时候了,希望你还有兴趣阅读我的下一篇文章。
【版权声明】原文作者未做权利声明,视为共享知识产权进入公共领域,自动获得授权。