DirectX12(D3D12)基础教程(十二)—

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);


![img](https://img-blog.csdnimg.cn/img_convert/8dade0c4a5645cfb92b448e0580aee6b.png)
![img](https://img-blog.csdnimg.cn/img_convert/d26a3c819f951a5de5c129ea5c794ee2.png)

**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**

**[需要这份系统化资料的朋友,可以戳这里获取](https://bbs.csdn.net/topics/618545628)**


**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**

);


[外链图片转存中...(img-GllGUi7E-1714228133817)]
[外链图片转存中...(img-ZOuJaorn-1714228133817)]

**网上学习资料一大堆,但如果学到的知识不成体系,遇到问题时只是浅尝辄止,不再深入研究,那么很难做到真正的技术提升。**

**[需要这份系统化资料的朋友,可以戳这里获取](https://bbs.csdn.net/topics/618545628)**


**一个人可以走的很快,但一群人才能走的更远!不论你是正从事IT行业的老鸟或是对IT行业感兴趣的新人,都欢迎加入我们的的圈子(技术交流、学习资源、职场吐槽、大厂内推、面试辅导),让我们一起学习成长!**

  • 11
    点赞
  • 26
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值