目录
1、前言
上一章中最后一个水彩画效果是有问题的,这一讲我们特意修正这个错误。其实错误主要是因为真正的水彩画效果还需要一个后处理——高斯模糊。
这一章主要的任务就是修正这个错误。同时这一章中将结合使用酷炫的多线程+多显卡渲染框架。为了能更好的理解本章教程示例,请各位最好已经仔细阅读了之前关于多线程渲染、多显卡渲染,以及Shader原始特效的章节文章及示例代码。
在本章中,多线程渲染依旧是用来录制渲染三个不同物体的命令列表。而多显卡渲染则主要用主显卡来渲染3D场景到纹理,而辅助显卡(核显)用来做后处理。后处理主要是实现水彩画效果以及两遍简单高斯模糊(垂直、水平分开各一遍)。
在我的笔记本上,主显卡就是来自N家的RTX2070,而辅助显卡就是Intel的核显UHD630,这是典型的混合显卡架构(我在这里不是为了秀设备的,请大家不要误解。而是因为现在大多数笔记本设备几乎都具有独显和核显,所以在需要高性能渲染时闲置任何一个都是“犯罪”,当然省电不是我们要考虑的问题)。
在之前的多显卡渲染示例中,已经介绍了使用无连接混合多显卡渲染的基本方法,在本章继续使用多显卡渲染有更进一步的目的,首先是为了让大家更加习惯并进一步书序多显卡渲染,其次主要就是为了让大家真正明白一个表面上很“孱弱”的核显,究竟能为我们做些什么?或者更确切的说,就是核显究竟能为我们的画质改进或者渲染性能提升带来什么样的效果?
本章中高斯模糊使用了与前一章原理性介绍不同的标准简化两遍高斯模糊,并且在性能上有明显提升。稍后会详细讲解改进优化的思路,这也是本章的重点之一。
本章示例运行后效果图如下:
使用键盘空格键可以控制水彩画效果的开关,Tab键控制高斯模糊效果的开关,Q键控制水彩画的随机采样半径,E键控制量化Bit数。
本章全部代码已经上传至GitHub:11-MultiThreadAndAdapter
2、多线程+多显卡整体渲染架构:
为了使大家能够彻底掌握和理解本章示例代码中的核心内容,首先来看一下这一次组合多线程+多显卡渲染后的程序整体逻辑。注意在这里依旧没有使用任何C++类封装,代码风格依然是C-Style,除了方便大家阅读代码外,更是为了让大家从整体上把握和理解多线程+多显卡渲染的整体框架。或者更贴切的说,是为了让大家能够从“上帝视角”来理解整个框架。
OK一图胜千言,下图中详细展示了本次示例的执行顺序框架:
上图只是展示了本章示例程序的大致执行逻辑顺序,特别要注意的是,图中先后顺序,并不是说GPU的过程与CPU的过程是串行执行的,本质上其实是CPU往GPU的命令队列中写入GPU命令的顺序而已,而CPU在调用了ExecuteCommandLists之后,CPU和GPU是真并行执行的。同时图中没有明显标识出Draw Call的调用,在D3D12的异步渲染框架中,其实Draw Call只是变成了一个命令记录而已,子线程录制命令列表最后一般都会写入一个Draw Call命令,同时每遍后处理最后也会写入一个Draw Call命令。通过前面的学习,这点各位应该不难理解。没有单独标出Draw Call的目的,是让大家不至于再混淆认为Draw Call时GPU就开始工作了,是耗时的调用。主要是为了让各位牢记或者说习惯现在D3D12中,实质上是调用ExecuteCommadLists之后,GPU才开始工作而已。
最终从图中可以看出,这个示例程序的框架其实与多线程渲染的框架大同小异,控制辅助显卡后处理的工作实质都是主线程单独完成的,因为到这一步,实质上CPU已经不用再做过多的工作了,所以一般也就不用CPU多线程来提高效率了。
当程序运行后主显卡和辅助显卡就是如下图形式的错帧运行了:
这个图在之前第八章教程中已经出现过了。需要注意的是,其中的时间值不是我们这个例子中的时间值,这里只是展示原理,我只是偷懒没有再重绘这个图了。
在我们实际例子中,辅助显卡主要是运行后处理,因为后处理基本上就是不断地处理屏幕像素大小的一张纹理而已,工作量基本上是固定的,所以其性能也是可以预期的。在本例中高斯模糊在沿用没有优化的“九宫格”方式的情况下,性能开销如下图:
从上图可以看出,未经优化的水彩画和高斯模糊后处理占用了UHD630显卡几乎50%的性能,这已经是非常明显的开销了。经过优化处理后,性能得到明显改善,优化后的性能开销如下图所示:
从上图可以看出,我们几乎优化了近30%的性能,实际运行中UHD630的性能开销平均大概稳定在20%左右。这样综合评估下来,因为我们的后处理实质上差不多是3Pass处理:一遍水彩画,然后分开水平垂直两个方向的高斯模糊处理,所以这样算来,如果让UHD630满负载运行后处理的大概可以进行15 Pass的后处理,这对复杂甚至更高分辨率的后处理来说已经足够了(当然需要实际的效果测试,并且打开Shader编译优化)。
这也就是为什么我一直热衷于类似我笔记本这种配置情况下的异构多显卡渲染架构的根本原因了。因为现代场景渲染中,后处理其实占用了很大一部分,但其工作量又通常比较固定,所以放到如UHD630这样的核显上来运行是再合适不过了,这样就可以将主显卡从多遍(Pass)后处理中解放出来,从而全力进行复杂的3D场景渲染。这样就尽可能大的发挥了系统的全部性能,并且可以明显看到画质的提升。
3、本章主要Shader
从前一章开始,本系列教程的重点就放到了Shader上来,因此关于本章示例代码中的C++代码部分我就不过多啰嗦了,除非有非常重要的需要注意的地方,大家可以自行从GitHub上下载阅读学习。
在这一章的示例中,由于使用了多遍的后处理,并且多次使用了渲染到纹理的技巧,所以综合下来Shader程序就比较多了。主要有下面几个Shader:
其中MultiThreadAndAdapter.hlsl文件中,主要是进行常规的基本的3D物体渲染,也就是第一遍渲染的Shader程序。例子中只是进行了简单的物体坐标系变换到视锥体空间的变换操作,以及最简单的Sample纹理得到物体像素点基本颜色的操作,这些没什么新奇的,有兴趣的可以自行加入光照、材质、法线纹理等内容,做出基本的光照模型,全当练手。因为这不是我们本章的重点,so 我就没有搞那么复杂了。这里友情提示一下,可以什么都学,但是千万别学我的偷懒!
其余的四个hlsl文件中,主要就是三遍后处理的Shader代码了。因为后处理实际都是针对已经被渲染成2D纹理图像的离屏表面进行的,本质上其实主要是运行Pixel Shader进行图像处理,而所有VS阶段只是简单的绘制一个全屏的矩形,并且不用带坐标系变换操作,所以我将Vertex Shader部分单独抽取出来,放在一个统一的QuadVS.hlsl中,这样后处理的三个PSO对象可以共用这个相同的Vertex Shader代码。其详细代码如下:
struct PSInput
{
float4 m_v4Pos : SV_POSITION;
float2 m_v2UV : TEXCOORD0;
};
PSInput VSMain(float4 v4Pos : POSITION, float2 v2UV : TEXCOORD0)
{
PSInput stResult;
stResult.m_v4Pos =v4Pos;
stResult.m_v2UV = v2UV;
return stResult;
}
这看上去实在是太简单了,仅仅就是简单的值传递操作。而程序中就是传递了一个归一化的单位矩形的顶点,原样输出就是了。
接着11-WaterColourPS.hlsl中和上一章代码中的水彩画特效大同小异,只是将对应的Pixel Shader代码搬到了这个单独的文件中而已。算法的过程就不啰嗦了,大家可以参考前一章。
11-GaussianBlurPS.hlsl就是优化后的简版标准高斯模糊的两遍Pixel Shader的代码了。详细的优化思路稍后会详细介绍。
11-DoNothingPS.hlsl就是最最简单的Pixel Shader,即只是从纹理中采样对应像素点的纹素值作为对应渲染目标像素点的颜色值。
4、水彩画效果
真正的水彩效果在shader中是比较难实现的,它需要进行中值滤波后累加等一些操作,还需要处理NPR(NPR是计算机图形学中的一类,即非真实感绘制Non-photorealistic rendering)中的笔触一类的概念。在本章示例中将绕开这些复杂的概念,只从视觉效果上能尽量模拟出水彩的画的那种感觉来。
水彩画一个最大的特点是水彩在纸上流动扩散后会和周围的颜色搅拌在一起,另外一个特点就是水彩通常会形成一个个的色块,过渡不像照片那样的平滑。针对这两个特点。可以设计这样的一个算法来模拟水彩画的效果。
首先模拟扩散。简单的说,可以通过随机对附近的象素点进行采样来模拟颜色的扩散,而这个随机区域的大小我们可以称为扩散的力度。这在C++代码里应该是非常容易实现的,读者只需要使用rand()函数就可以了。但是HLSL并没有提供这样的函数,怎么办呢?这时可以采用噪声纹理的方式,既事先计算好一个n*n大小的随机数数组,并作为纹理传递给Pixel shader,这样在Pixel Shader里就能获得随机数了。得到随机数后,将随机数映射成纹理坐标的偏移值,就能模拟出色彩的扩散了。典型的噪声纹理是这个样子的:
这像极了古老的阴极射线管电视机在没有信号时的样子,有人说这就是宇宙微波背景辐射的样子!
接下来处理色块,对颜色的RGB值分别进行量化。把RGB分量由原来的8bit量化成比特数更低的值。这样颜色的过渡就会显得不那么的平滑,而是会呈现出一定的色块效果。
通过以上两步处理后,图像依然有非常多的细节,尤其是第一步处理中产生的很多细节噪点。通过平滑模糊的方式来过滤掉这些高频噪声成分。
在Shader代码中的g_fQuatLevel变量即被用来表示对图像的量化比特数,值越小,色块越明显,比较合理的取值范围是2至6。g_fWaterPower则表示图像颜色扩散范围,取值范围在8至64之间的效果比较好。
最后需要两个pass来完成这个算法,第一个pass叫flow pass,模拟颜色的流动和处理颜色的量化。第二个pass叫Gauss pass,也就是前面提到的高斯模糊算法。实现的重点在第一个pass。
在模拟扩散的pass中,同样需要一个渲染目标,以把结果保存在其中以便后续处理,然后还需要一个噪声纹理来产生随机数。在例子中噪声纹理是通过主渲染线程上传到辅助显卡上的。
Shader和代码这里就不粘贴了,大家可以去GitHub上下载查看。
5、Shader函数的本质与PSO对象
这里要重点提醒大家的一个Shader知识,就是在所有的HLSL Shader代码中,所谓的函数都是内联的。当然这在《DX12龙书》上是一笔带过的,其实这背后隐藏了一个惊天的秘密!这个秘密对我们理解整个渲染管线以及围绕PSO展开的一系列编程工作都非常非常重要,so 这里就有必要啰嗦的把这个问题讲清楚,以便大家从本质上理解整个GPU的工作原理以及渲染过程中Shader的运行机理,从而使大家能够从本源上理解Shader代码的编写,进而达到能够挥洒自如的境界。
HLSL中函数全是内联的,那么这究竟是什么意思呢?其实本质上说,即使像前面描述的最简单的VS代码中的VSMain函数,在编译后,传递至GPU最终运行时都是没有类似CPU中的函数调用相关指令的。
如果了解C/C++语言编译器或者说了解CPU指令及汇编代码的话,应该对CPU上的一般函数调用过程指令不陌生了,通常在CPU上,需要进行参数入栈或入寄存器操作,然后执行Call指令跳转(远古时代的CPU上其实就是用Jmp指令)到函数机器码的开始地址,执行函数体机器码,最终函数内再执行Ret指令跳回Call指令后的机器码地址,并还原栈或寄存器。这一套指令,尤其对于x86架构的CPU来说,甚至对于如ARM架构的CPU来说都是最最基本的指令。或者说,在如C/C++等针对CPU写的代码中,函数调用是会生成真实对应的函数调用指令机器码的。甚至如Java这样的虚拟机上也有完整的函数调用指令及参数出入栈等相关指令的。
而对于GPU来说,它是没有函数调用框架指令的,所以任何写在Shader中的HLSL代码其实都是不生成任何函数调用指令的,从而都采取了“内联展开”的方式。或者简单的理解就是,函数调用的语句都被函数体中的语句给直接替代了。
这有点像我们把某个函数的代码复制粘贴到被调用的地方,然后把所有函数定义及调用的相关语句全部删除,然后做下变量名替换,从而消除了参数传递赋值、函数定义和函数调用等语句,带来的直接好处就是程序“线性”执行,从而更加高效。
更直接的,可以直接阅读本系列教程中的C++代码,其实就已经是这种风格了,我基本上都没有封装什么函数,全部都是在WinMain函数中一篇代码写到底。这与使用“函数内联展开”至少在原理的理解上是一致的。
当然对于没有学过《编译原理》或不了解编译原理的网友来说,能理解成这样既可。实在不明白先不要纠结,请继续往下看。因为这个问题我自己都觉得太啰嗦了,但又太重要了,所以简单理解了之后请耐心继续往下看。(so 细思极恐的问题:《龙书》中可能还漏掉了些什么?或者它只是“绝世武功”的目录?)
当然这样编译Shader代码的根本原因是因为GPU架构的要求。
首先这样安排的主要好处,就是省去了函数调用相关的框架指令带来的潜在性能开销,否则类似C++这样的语言中也不会煞费苦心的添加内联函数这样的语法了。函数调用性能损失主要在指令跳转,调用堆栈的申请释放,寄存器的保存、使用及恢复等,以及潜在的高速缓存命中失效等等这些耗费时间空间的指令上。而这些在GPU上就全部省略了,从而带来了算法程序的高效能执行!
另一方面,实际应用中的Shader代码整体来讲结构其实都并不复杂,但算法逻辑可能很复杂(逻辑复杂跟结构复杂性是两个维度的问题,不要混淆!),其逻辑主要集中在各种算法的核心实现上,并不需要过多考虑结构方面的问题,更不需要复杂的所谓结构化编程或面向对象编程方法的支持,因此在GPU上设计复杂而用处不多的函数调用框架指令就显得毫无必要了。而这些完全可以靠Shader编译器以及CPU的能力补齐。
没有了这些开销(想想那些密集微小并且高度集成的晶体管,因为需要更复杂的结构而加入成至少几千倍的数量增加),就保持了GPU上单个流处理器的结构简单并且运行高效的特性,从而可以在一个GPU上集成成千上万个流处理器形成高密集高并行的计算阵列。
现代典型的GPU计算单元原理图如下(这个图有点老,各位可以自行百度比较新的GPU架构图):
图中一个Core就是一个最小的流处理器单元,它的核心就是执行向量计算的运算器,可以简单的理解为有四个分量的ALU并联的一个计算单元。这些Core共用控制逻辑模块、指令寄存器及缓存、Texture单元、内存(显存)控制单元等。在典型的GPU上,这些组件又合成一个较大的单元,NVIDIA系GPU中称之为SMX单元,而AMD系GPU中称之为GPC单元(最新的叫法自行百度了,我继续偷懒不写了),这样这些较大的单元又可以使用多个组合从而形成一个并行计算能力超级强悍的GPU。如下图所示:
另外我们可以通过GPU-Z软件看到GPU上有多少个Shaders,注意这里的Shaders和我们说的GPU上运行的Shader程序不是一个意思,这里的Shaders一般就是指GPU上最小的执行单元流处理(Core)。比如我的笔记本上运行GPU-Z后就显示如下:
而UHD630则显示如下:
ok,从上面至少可以直观的看出为什么我们说CPU上的核显“孱弱”了,因为流处理器的个数已经直观的反应了孰强孰弱了。当然因为这两个显卡的厂商以及细节上的架构的巨大差异性,直接比较Shaders数量来评价性能,并不是很客观。其实Shaders一般Intel的在结构上要比Nvidia的复杂一些,单个的Shaders实际运行能力上来说Intel的也是要比Nvidia的要强一些。基于此,我们可以这样来比较,就是给每种Shaders一个性能权重值,比如可以给Intel的Shaders一个性能权重值1.5(或者干脆给2.0),给Nvidia的Shaders一个性能权重值1.0,然后用权重值乘以其数量再来比较,就比较合理一些了,这时实际比较值就是2304*1.0 > 24*1.5(或2.0)。当然最终的权重值需要根据性能测试数据来评估。我这里只是取一个经验值。重点是让大家不要简单的被Shaders的数量给迷惑了。
ok,扯了这么多关于GPU Shaders架构方面的闲话(不是知识哈,这里只是当做背景描述一下而已,想详细了解的最好去自行百度一下相关的文章充充电,而且您最好是这样做了),我们继续HLSL中全是内联函数的话题。
基于对GPU中最细小的计算单元Shaders的这种基本认识,其实大家可以想象一下,如果为这些公用了很多其它单元如Texture、指令单元、内存控制单元等的每个“小家伙”都设计实现一套函数调用框架指令出来会是怎样的一种挑战?这就好像非要为一个学校中的每个孩子都设计制造一个独立的卫生间一样!
况且我们不止一次的说过,GPU其实就是一个巨大的SIMD(单指令多数据流)架构的处理器,它的核心目标就是为了高速并行的按照既定的算法并行处理海量的相似的且基本两两毫无关系的数据的(并行条件,自行百度),比如一个Mesh中成千上万的顶点、索引等等数据,而现代的一个游戏中几乎有成千上万个这样的Mesh以及不同的几乎上百的纹理图,而每个纹理图又几乎都是4K*4K分辨率的情况下,它能高效并行处理就行了,不需要考虑额外的东西(更不需要考虑什么硬盘读写控制、键盘输入、鼠标输入等等,这些都有CPU做了,GPU根本就不用理会)。
而什么样的情况最高效?那就是将整个处理逻辑都“线性”的组织起来,即第一步干什么、第二步干什么、第三步干什么,等等,然后得到结果即可,当然还需要再加一些简单的条件分支或简单的循环即可。而这是被证明了的定理,即一个算法程序需要的最基本的“正交语法”要素,就是基本的顺序语句、循环语句和判断语句,这些足可描述所有的算法!这里根本不需要什么函数!而需要函数的根本目的只是为了方便写代码时好组织代码结构,并且不需要写大量重复的代码语句而已。说白了是为了方便人类“偷懒”而已(某种意义上说“面向对象”也是基于同样的目的,它不是用来为描述算法设计的),而函数跟最终的算法表达本身则毫无关系。
所以在GPU硬件、以及Shader编译器翻译结果上都将函数的概念完美的抹掉了,只是在HLSL语法中,保留了函数,方便人类写程序而已,这样鱼和熊掌就兼得了!同时对于我们最终Shader程序员来说,压根也不知道还有这么一回事,所以《龙书》中也就一笔带过了。
啰嗦到这里,您可能会说我这是不是有病?啰嗦这么多干什么?一个函数内联展开的问题至于吗?我可以负责任的说,首先我没病,其次说这些的重点不是在这个HLSL全是内联函数的问题本身上,而是说这种要求,其实给我们带来了在Shader编程及理解上的麻烦问题。比如为什么要有根签名?为啥非要有个PSO对象?为啥HLSL语法中要一大堆的所谓“语义文法”?其实根本上这些就是因为HLSL中全是内联函数所造成的。
那么我们就一个一个来详细耐心的理解这些问题。
首先,大家还记得我在讲如何理解根签名时提到过一个概念,那就是将整个渲染管线想象成一个大的函数体,而根签名就是这个函数体的函数声明吗?
结合HLSL中全是内联函数,是不是恍然大悟?其实说白了,根签名就是为了干这个活的,因为像我们例子中的Vertex Shader函数的所谓“入口函数(主函数)” VSMain,以及Pixel Shader的对应函数PSMain等都是没有函数调用指令的,最终编译后的机器码在GPU的指令缓冲中都是“平铺直叙”的,那么GPU怎么了解要从哪里取得常量缓冲?或者从哪里取得纹理纹素的?等等类似的“传参问题”?所以准备好一个根签名说清楚就是了!当然这个根签名就需要“编译”一下变成GPU能够理解的形式。
其次,需要传递的最最重要的Vertex Buffer、Index Buffer等数据参数的具体描述就被放在了PSO对象中,而在根签名中只是用一个状态标志告诉GPU“输入参数”中还有顶点数据等而已!那么PSO对象其实就是整个渲染管线这个“大函数体”的全部程序指令以及状态集的超集,类似于CPU Thread的Context!所以我们就将PSO直接理解为GPU的Thread Context。只是写CPU代码时这些对于程序员来说是透明的,比如只写单线程的程序,压根就不需要知道线程是何物。但在GPU上,因为所有的程序就是一个大的函数体本身(不是单线程的,参看之前文章),所以必须要对这个函数体的状态甚至代码都做额外的管理,而这些任务就落在了CPU身上。具体的就是CPU创建PSO对象并设定初值及参数,然后设置为GPU的当前Context,而GPU直接执行即可,不需要再做额外的管理了。(CPU上的线程是完全由CPU自身来管理的,所以现代CPU中有很大一部分指令被称为系统管理指令,详细的可以参看Intel CPU指令手册第三卷。可以想象一下,如果GPU上的每个微小执行单元都这样设计带来的复杂度和冗余度将多么恐怖)。
再次,因为所有的HLSL Shader中的函数都需要内联展开了,那么编译器其实有个“选择困难症”的问题,那就是究竟HLSL程序中那些被传来传去,名字可能更是变来变去的具有丰富类型的参数究竟怎么样去明确的对应呢(或者直白的说,函数的壳怎么剥呢,尤其是VS和PS可能还在不同的文件中分开编译)?复杂的情况下,可能输入的是一个有三个变量的结构体,而被调用的子函数只需要传递一个,并且可能先被赋值给了局部变量,然后再传给了子函数,而这个子函数可能又被放在别处,并被别的Shader程序调用。当然对于有“智能”的“人工”程序员来说,区别这些都是些基本功而已(广东话:撒撒水啦!),但对于需要把所有层层嵌套关系的“复杂”结构化程序(这里是相对的复杂)中的函数调用关系完全“捋顺”并且“平铺直叙”出来的编译器来说,它即无“人工”也无“智能”,只是一个固化的算法而已,怎么办呢?或者从另一方面来说,要编译器完全“拆掉”所有的函数调用结构,那么本身用于机器码生成时的寄存器安排算法或堆栈申请释放算法(编译器中较重要的算法),就毫无用处了,这时GPU上的寄存器又怎么样去安排呢?于是基于这些困难(这里说的还是不够深刻,想深刻了解的请去补充编译原理中函数内联展开算法的相关知识及困难问题就可以彻底明白我在说什么了,或者想一下为什么C++中的内联函数被要求放在头文件中),HLSL编译器的设计者或者说Shader编译器的设计者们就想出了一个非常“智能”的“人工”办法,那就是谁写HLSL程序,谁就在变量后面用“:”扩展的“语义文法”说清楚对应的是哪个寄存器!当然对于函数的返回值也需要这样来说明。这就是HLSL中或者说Shader中“扩展语义文法”(比如:register(b0)、POSITION、TEXCOORD)等等的由来(这里不要细究,我是猜的!)。
最终,基于上面这一通啰嗦的描述,您应该明白了,怪不得我们在写HLSL的Shader程序时,并且想让GPU执行渲染算法时,需要那么多复杂的语法以及编程概念对象:根签名、PSO等等,原来如此!
这样在我们深刻的理解了这些问题以及概念后,那么对于PSO对象本身应该有了更深刻的理解,其实PSO就是GPU上线程体(通常被称为Thread Box)的Context(如果您看过我写的一些多线程调用的文章,那么一定会想起我说过将Thread理解为函数调用器)!
如果需要不同的渲染效果或者说不同的渲染算法,那么就请准备不同的PSO,然后来回不断的切换PSO运行即可。那么自然而然的,我们就可以自己推断出以下这些PSO使用方面的建议:
1、尽量将相同PSO渲染的数据(物体)放在一起渲染,因为不论CPU还是GPU,Thread Context切换必定都是代价高昂的操作,尤其对GPU更是如此;
2、切换PSO过程中,尽量让切换之间的相关复杂大量的数据变化尽可能小,比如两个PSO使用相同的Texture、相同的Vertex Buffer或者相同的Const Buffer等,就放在先后临近的顺序来执行;
3、创建PSO对象时(包括编写对应的HLSL程序),实际就是在创建GPU 的Thread Context,因此每个Context中的寄存器都需要考虑到,写入正确的值,并且保持状态一致,否则PSO的状态就可能是错的,渲染结果自然就不正确了。而这就是我们编写D3D渲染程序或者类似的OpenGL渲染程序中最大的“体力活”,也是个需要有“绣花”般耐心的活计!
4、一定要保证在HLSL用到的所有寄存器,都在根签名中或PSO的Input Layout中进行了描述。同时所有的渲染阶段状态:Rasterizer State、Depth Stencil State、Blend State等都设置了正确的状态。当然如果多传了比HLSL需要的变量更多的值,这当然不会引起问题,这只相当于多占用了寄存器,而渲染过程中根本都没有用到而已。这就好像一个只需要5个参数的函数,它的声明中可能需要传入远大于5个值的情况一样,这没有任何问题。
以上就是因《龙书》中的一句话引发的一通神侃!看不明白也没有关系,记住最终几条建议也基本达到目的了。当然不要忘了再多思考下那个“细思极恐的问题”!
6、高斯模糊优化
洋洋洒洒几千字浪费完了,终于可以说这一章的真正重点了,那就是关于高斯模糊的优化问题了。或者现代牛逼的Shader程序员们是怎样玩高斯模糊的,因为之前我们使用的“上古”时代的玩法。如果真的那么用了,可能会被同行笑话的,所以本着对人生负责的态度,这次我们将从“上古”时代回到“现代”来!
首先高斯模糊是一种柔和模糊的图像效果。模糊后的图像可以被更复杂的算法用来产生形如炫光(bloom)、景深、热浪或者磨砂玻璃的效果。这一章我们就会将它用在了“水彩画”效果的后面,从而完整的实现整个效果。
当然主要的目的是为了跟大家讲清楚两个方面的问题:
1、就是现代的后处理效果往往都是需要n遍后处理的;
2、就是Shader的主要优化方法之一就是尽可能的减少Texture的Sample操作;
在本文中,我将会讲解如何利用高斯滤波器的优良特性来提高实现的效率,和利用Sample操作中双线性插值的特点,来提高高斯模糊效率的技巧。虽然本文讲述的重点是优化高斯模糊滤波器,但其中主要优化的手段都可以运用在实时渲染中的其他卷积滤波器上。看明白之后,建议有兴趣的各位将前一章中的其他“上古”时代的滤波器都改成“现代”版本来巩固下所学的知识。
6.1、并行计算条件和多遍渲染
首先来思考一下,为什么上一章的水彩画效果是有问题的?或者说为什么不能在一遍渲染中同时完成“水彩画”运算和高斯模糊效果呢?
为了彻底理解这个问题,我们先来看下水彩画效果渲染和高斯模糊渲染实质上是在干什么?其实这个过程如下图所示:
图中左边的纹理就是第一遍3D场景渲染的结果,通过对它的两次采样(Sample,一次对应像素点坐标,一次随机)及混色计算后就得到了屏幕上对应点的颜色值,接着如果在同一遍中继续进行高斯模糊的Sample操作时,会遇到问题,因为临近点像素的颜色值是无法预知的,同时在HLSL里也没有直接的方法说让我们从渲染目标纹理中直接进行采样,所以这第二遍高斯模糊的采样操作是无法继续的。
这时可能会想到,如果HLSL允许对渲染目标纹理的直接采样,那岂不是可以直接进行高斯模糊处理了?其实即使真有这功能,这个处理也是无能为力的,因为临近像素点其实在另一个并行的Shader里运行,而它可能也在等临近像素点着色完毕后试图运行高斯模糊的Sample,并取得临近像素点的颜色值,这时如果深刻的理解并行死锁问题的话,就会发现这本质上是一个资源互相等待的“死锁”问题,并不能简单的解决。
其实这个问题的深刻本质就是它违背了如下的并行计算条件,即如果两个算法的输入集合和输出集合分别为I1、I2和U1、U2,那么这两个算法能够并行运行的充要条件是:
上面的数学化的说法,翻译成大家都明白语言就是说,两个算法间的输入数据不能有交叉,同时一个算法的输入数据任何一部分不能是另一个算法的任何输出数据。
而前述问题,就违背了这第二条规则,即我们的水彩画效果算法的输出是高斯模糊效果的输入数据。所以它们根本上是没法并行运行的!因此在代码层面上也是没有办法让它们运行在同一遍Shader中!这两个算法的实际关系是U1=I2。而这正好就是两个算法“严格串行”的条件。
从以上的分析中,必须要记住和领悟的就是这个并行计算的充要条件,数学就是这么神奇和强悍。因为从根本上说Shader编程就是在实现并行计算的算法,所以在具体编码时就必须要牢记这个条件。不满足条件时,就必须乖乖的退回到串行计算的方法上来,一步一步的分开执行。
最终当知道了问题的本质之后,那么解决起来就毫不费力了,只需要将高斯模糊放在水彩画效果之后作为独立的一遍渲染即可。
当然这也就是大多数后处理需要反复多遍的一个根本原因!
6.2、高斯模糊优化思路及实现
关于高斯模糊的数学部分,根据我一贯的风格,就偷懒滤过了,各位可以在CSDN找到N多大牛们的精彩的讲解,我就不啰嗦了。
那么我们来直接分析“上古”版本中的性能问题。因为“上古版本”中为了通用滤波的目的,就专门设计了一个基于“九宫格”的滤波函数如下:
float4 Do_Filter(float3x3 mxFilter,float2 v2UV,float2 v2TexSize, Texture2D t2dTexture)
{//根据滤波矩阵计算“九宫格”形式像素的滤波结果的函数
float2 v2aUVDelta[3][3]
= {
{ float2(-1.0f,-1.0f), float2(0.0f,-1.0f), float2(1.0f,-1.0f) },
{ float2(-1.0f,0.0f), float2(0.0f,0.0f), float2(1.0f,0.0f) },
{ float2(-1.0f,1.0f), float2(0.0f,1.0f), float2(1.0f,1.0f) },
};
float4 c4Color = float4(0.0f, 0.0f, 0.0f, 0.0f);
for (int i = 0; i < 3; i++)
{
for (int j = 0; j < 3; j++)
{
//计算采样点,得到当前像素附件的像素点的坐标(上下左右,八方)
float2 v2NearbySite = v2UV + v2aUVDelta[i][j];
float2 v2NearbyUV = float2(v2NearbySite.x / v2TexSize.x, v2NearbySite.y / v2TexSize.y);
c4Color += (t2dTexture.Sample(g_sampler, v2NearbyUV) * mxFilter[i][j]);
}
}
return c4Color;
}
这里让我们仔细看下这个函数,首先它里面用了一个嵌套循环,当然这还好,因为它总共执行3*3=9次而已,GPU上的小Shaders们还扛得住。但是请仔细注意循环里面的Sample函数,它也执行了9次!
嗯,这有什么大惊小怪的?难不成我又开始犯病了?no no no,我们先分析下这个Shader函数执行的场景,这是在Pixel Shader中被调用的一个子函数,ok 根据之前我们啰嗦的那一大通,至少现在我们知道它肯定被内联展开了,所以函数调用是不耗费什么性能的。
继续往下分析,Pixel Shader是怎么被执行的?因为我们这是后处理,所以Pixel Shader的主函数PSMain是按照每个像素调用一次的,理论上这些都是被GPU上的成百上千个Shaders同时并行执行的,因为这是个后处理,总的执行次数就是屏幕像素大小个数次,在我们的例子中这是:1024*768*9=7077888次!ok 我想大多数程序员这一生都难得写个执行这么多次的循环,这还没有算假如是4K分辨率情况下的次数。当然幸运的是,这是并行执行的,从理论上以我的UHD630(本章例子中后处理主要在核显上执行)上每个Shaders的执行次数来算是:7077888/24=294912次,看上去还行。当然实在不行,那就不要多显卡渲染了,让NB的N卡来搞不就行了,这时我们有:
7077888/2304 = 3072!啊哈!恭喜您发现了并行计算最大的秘密,那就是并行执行的单元越多,执行的效率就越高!其实这是废话!因为能同时执行的单元越多,这样的设备也就越贵!而我们真正需要的是想尽一切办法降低单个执行单元上的开销!
然鹅,这只是1K分辨率都不到的情况下一帧画面的一遍后处理中的调用次数,如果按照4K分辨率,144HZ刷新率的要求,再加上主显卡往往承担着整个场景渲染压力的情况下,这个开销就变得有点吃不消了。
根据之前描述的Shaders的基本架构,它们其实还要共用Texture单元等。这里让我们先想象在这样一所学校,全年级的学生只有一个单坑位的厕所,然后下课后排队上厕所的窘迫场面。当然实际情况也许没这么糟糕,但是当大家都是下课时间统一上厕所时,即使有多个坑位,也需要排队的情景,我想各位都可能经历过。而Sample操作就是这样的一种情景!所以它是很低效的,或者说很耗时的,而且每个像素需要调用9次,这可以理解为平均每个学生需要占用坑位9分钟,而下课只有10分钟!这样一来,“上古”时代的高斯模糊实现,让我的UHD630核显占用率几乎达到了50%就毫不奇怪了。
这里非常感谢您捏着鼻子看到了这里!那么怎么来优化这个恐怖的“抢厕所”问题呢?
这需要从算法本身来寻找突破口,记住这是所有性能优化问题第一件要着手做的事情。这里幸运的是,高斯模糊其实是可以降维处理的,也就是原来的基于“九宫格”的二维滤波,是可以被降维成两个一维的滤波来实现的(《三体》中,外星人就是先对人类进行了降维打击,效果就是人类差点被彻底消灭)。原理我就不多说了,原因你懂的!具体的也就是变成横向(水平方向)3个像素分别采样一次以及竖直(垂直方向)3次采样来实现,当然这需要分开成两遍后处理,也就是一遍进行水平3像素采样,而另一遍换成垂直方向采样3次即可。同时顺手也把嵌套循环给消灭了,具体优化后的代码如下:
//这个Shader改编自微软官方D3D12示例,删除了不必要的VS函数,以及啰嗦的为测试性能而编造的假循环
struct PSInput
{
float4 m_v4Position : SV_POSITION;
float2 m_v2UV : TEXCOORD;
};
static const float KernelOffsets[3] = { 0.0f, 1.3846153846f, 3.2307692308f };
static const float BlurWeights[3] = { 0.2270270270f, 0.3162162162f, 0.0702702703f };
// The input texture to blur.
Texture2D g_Texture : register(t0);
SamplerState g_LinearSampler : register(s0);
// Simple gaussian blur in the vertical direction.
float4 PSSimpleBlurV(PSInput input) : SV_TARGET
{
float3 textureColor = float3(1.0f, 0.0f, 0.0f);
float2 m_v2UV = input.m_v2UV;
float2 v2TexSize;
//读取纹理像素尺寸
g_Texture.GetDimensions(v2TexSize.x, v2TexSize.y);
textureColor = g_Texture.Sample(g_LinearSampler, m_v2UV).xyz * BlurWeights[0];
for (int i = 1; i < 3; i++)
{
float2 normalizedOffset = float2(0.0f, KernelOffsets[i]) / v2TexSize.y;
textureColor += g_Texture.Sample(g_LinearSampler, m_v2UV + normalizedOffset).xyz * BlurWeights[i];
textureColor += g_Texture.Sample(g_LinearSampler, m_v2UV - normalizedOffset).xyz * BlurWeights[i];
}
return float4(textureColor, 1.0);
}
// Simple gaussian blur in the horizontal direction.
float4 PSSimpleBlurU(PSInput input) : SV_TARGET
{
float3 textureColor = float3(1.0f, 0.0f, 0.0f);
float2 m_v2UV = input.m_v2UV;
float2 v2TexSize;
//读取纹理像素尺寸
g_Texture.GetDimensions(v2TexSize.x, v2TexSize.y);
textureColor = g_Texture.Sample(g_LinearSampler, m_v2UV).xyz * BlurWeights[0];
for (int i = 1; i < 3; i++)
{
float2 normalizedOffset = float2(KernelOffsets[i], 0.0f) / v2TexSize.x;
textureColor += g_Texture.Sample(g_LinearSampler, m_v2UV + normalizedOffset).xyz * BlurWeights[i];
textureColor += g_Texture.Sample(g_LinearSampler, m_v2UV - normalizedOffset).xyz * BlurWeights[i];
}
return float4(textureColor, 1.0);
}
这样两次后处理中,每次每个像素只执行了3次Sample,总的就相当于执行了:1024*768*(3+3)=4718592次!在UHD630上,理论值就变成了:4718592/24=196608。比原来少了30%,而这与实际测试的结果相符,优化后的GPU占用率确实下降了约30%!
当然多出来的步骤就是多了一遍后处理,但这个开销基本是固定的,无非就是多创建一个离屏表面,然后切换下PSO对象,因为水平和垂直两个方向的一维高斯模糊具有关联性,所以如前所属这个PSO切换的性能开销也是极小的,从算法优化的角度来说,常量的时间开销是可以不记成本的!
更进一步在本章例子的基础上,还可以进一步将全局常量直接写在循环内部,而将for循环也去掉,从而使整个Shader函数变成完全顺序结构,这样就不需要在GPU上执行开销较高的循环指令了,从而进一步得到优化。
这里还要提醒各位的就是,这章的Shader中,再没有啰嗦的以全局常量的形式向Shader中传入纹理像素尺寸大小了。而是替代以调用GetDimensions内嵌函数。使用这个函数可以直接在Shader中得到纹理的像素尺寸,这样就省心多了。综合下来本章的高斯模糊实现是即省心又省时,实在是“偷懒”的妙招!
总结下来,优化核心思路就是优先进行“降维打击”!其实,从一般算法优化角度讲(包括串行算法),这也是一个常规的套路,本质上就是将一个O(n^2)时间复杂度的算法(“上古”实现是3*3)改进成了一个O(n)时间复杂度的算法(现代实现是3+3),确实是执行了降维,而大多数情况下,很多O(n^2)时间复杂度的算法能改进到O(n*log(n))或O(n*ln(n))时间复杂度就已经是很大的优化了。
更进一步的,其实很多滤波算法中n其实是比例子中的3要大的多的值,比如更好效果的高斯模糊其实至少在每个像素周围采样5*5以上的像素点,这时如果采用了“降维”打击,效率提升将更加明显。降低的因子就是(5*5)-(5+5)/(5*5)=15/25=60%,也就是理论优化性能提示在60%,这是一个很可观的效率改进!
更一般的,大多数基于Shader程序的滤波处理(也叫“滤镜”效果),都可以进行“降维打击”,也就是将一遍后处理变成水平-垂直两遍后处理,将嵌套循环变成一个单次循环,或者更进一步将单次循环改成顺序结构。比如前一章例子中的很多基于二维“九宫格”实现的“上古”滤波效果,就可以执行“降维打击”,当然最终这个艰巨的任务就交给各位了!
暂无评论内容