Direct3D提高篇:HLSL编程实现PhotoShop滤镜效果(1)
关于学习,中国有句古话叫“学以致用”,可见把学到的东西用于实际实践中是多么的重要。现在学习Direct3D/HLSL的人非常多,教程也非常多。但是很多人不知道看完这些教程后该干什么,或者说可以怎么利用学到的知识。本文会对Direct3D/HLSL做一个简单的介绍,讲述如果将HLSL用于数字图像处理,带领大家一起体会HLSL的强大。
简介.
1)Direct3D和HLSL
Direct3D是微软开发的用于编写Windows下高性能图形程序的3D API。通过Direct3D,我们可以访问高速的图形加速卡。它是DirectX众多成员的一部分。
HLSL 全称High Level Shading Language 。是微软推出Direct3D 9时的一个重要更新。所谓的Shading Language还需要从Direct3D的图形管道说起,Direct3D在Direct3D 8以前只能工作在固定管道(Fixed Function Pipe-line)的模式下,在固定管道模式下,图元从提交到被转化成可以显示的像素是按照实现定义好的流程和算法来完成,可以认为是固化在硬件中的死功能。
从Direct3D 8开始,Direct3D中引入了可编程管道(Programable Function Pipeline)的概念。在可编程管道中,开发人员可以自己编写用于处理顶点和像素的程序。这些程序是运行在GPU上而不是CPU上的。在Direct3D里面,用于处理顶点的程序叫Vertex Shader,用于处理像素的叫Pixel Shader。(目前最新的Direct3D10中又引入了Geometry Shader的概念)。因为硬件的水平在进步,所以可编程管道的处理能力也在不断的提高。根据不同的硬件能力,Shader的版本也已经有对应的不同版本。从Direct3D发布的最早的Shader Model 1.0到现在主流的Shader Model 3.0,可编程管道已经能提供一定范围的通用编程能力了,这就是所谓的GPGPU。
HLSL是一种高级语言(High Level),之对应的是Low Level Shading Language。这个低级的语言就是ASM的Shader。它是类似于汇编语言,难以编写和维护。而HLSL则跟我们熟悉的C/C++语言非常类似。大大降低了开发人员学习的成本。HLSL本身就是微软和nVidia联合开发的,nVidia的版本称为Cg,也就是C for Graphics。可想而知,它和C是有同样的血统的。
本文简要介绍了Direct3D和HLSL,如果读者希望深入了解,请访问MSDN等相关网站。同时关于如何在Direct3D应用程序中使用HLSL编写的Vertex Shader和Pixel Shader,请参阅其它的教程和微软的DirectX SDK。
2)RenderMonkey简介
现在的开发人员可能都比较熟悉IDE的工作模式,尤其是使用Visual Studio一类开发工具的Windows程序开发人员。HLSL作为一种新的语言,GPU编程作为一种新事物,目前还没有很好的IDE能完整的支持编写,调试一体化的工作方式。在本文我们将使用ATI的HLSL开发IDE: RenderMonkey。
RenderMonkey是由前ATI开发的,用于编写Shader,并调试的工具。由于RenderMonkey支持插件,所以既可以编写OpenGL的GLSL也可以编写Direct3D的HLSL。它能支持创建RenderTarget,多Pass渲染,可以自由选择用哪个shader model来编译代码。并能加亮显示shader代码。
经典的RenderMonkey界面如下图

左边为工作区,右边为预览区域。下面为信息输出区。在左边的工作区里可以看到。我们可以对Shader的工程进行分组,其中每一个可以独立工作的工程称为一个Effect。在同一时候预览区中只能预览当前激活的Effect。每个Effect由不同的对象组成,其中比较重要的对象如下:
1) Pass . 这个pass就是渲染中常提到的pass.代表一遍的渲染
2) 几何体。就是类红色茶壶表示的,它代表在渲染中使用的几何体。
3) 纹理对象和RenderTarget对象(用一个铅笔表示)
4) Shader中用到的参数,这些参数可以是自定义的,也可以是预定义的(比如当前的观察矩阵,摄像机的位置等参数)。
5) 每个pass中用到的Shader。这些shader可以在RenderMonkey的代码编辑器中进行编辑,并调用命令来编译。
因为文章篇幅的关系,本文将直接使用RenderMonkey来作为演示的平台。关于如何使用RenderMonkey,请参照RenderMonkey的帮助,或者打开RenderMonkey自带的例子,很容易就能掌握这个工具的使用方法。
GPGPU
本文将要介绍的是如何用HLSL来实现PhotoShop的滤镜效果,也就是说需要通过GPU来进行数字图像处理。这是目前非常流行的GPGPU的应用的一种。
GPU是专用于处理3D图形显示的处理器,虽不如CPU的指令集丰富,但GPU的指令集更加有针对性,因此这就决定了GPU在牺牲了的灵活性的前提上有更快的运行速度。GPU特别适合处理那种可以大规模并行的算法,比如某些数字图像处理算法。
因为目前我们的程序只能通过Direct3D的API才能访问到GPU,一般我们采用Pixel Shader来进行GPGPU,所以要使用GPU来处理数据的时候,必须完成以下几件事:
1).将数据提交给GPU
2).调用对应命令让GPU开始处理数据
3).从GPU哪里取回处理完毕的数据。
可以通过两种方法将数据提交到GPU,纹理和shader的参数,纹理中一般保存我们需要进行处理的数据,而shader参数则一般是用于数据处理算法需要用到的一些参数。
当数据已经准备完毕后,调用Direct3D的drawPrimitive函数在屏幕上绘制一个纹理相同大小的矩形,把GPGPU的算法写到用于绘制这个矩形的Pixel Shader中。Direct3D开始绘制这个矩形以后,会为每一个象素调用一次整个Pixel Shader,然后把Pixel Shader的输出写入到RenderTarget中。因为我们绘制的矩形的大小和纹理的大小是一致的,所以输出象素和纹理的象素可以做到一一对应的关系,也就是说纹理中的每一个象素在经过Pixel Shader的运算后被输出掉RenderTarget里,等于对这个数据调用了一次我们需要的算法。现代的GPU中往往有大量的Pixel Shader处理单元,而这些处理都建立在非常快速的并行运行的基础之上的。
经过前面的步骤,处理完的数据已经到了RenderTarget里了,我们可以事先自己创建一个RenderTarget(通常和输入纹理等大)来接受步骤2中的数据,然后Lock这个RenderTarget取回数据。也可以在步骤二中可以使用一种低效率的做法,即直接把图像绘制到屏幕上,通过Capture屏幕来得到输出。
GPGPU简单介绍到这里。详细的GPGPU资料请参考www.gpgpu.org。 同时nvidia的网站和发布的SDK上也有很多关于GPGPU的例子。
接下来我们使用RenderMonkey来搭建一个用于数字图像处理的架子,以实现类似PhotoShop的滤镜效果
RenderMonkey图像处理的架子-图像黑白化
下面我们通过一个简单的例子,先来完成一个最简单的图像处理-把图像黑白化。来说明RenderMonkey如何来处理数字图像。
Render Monkey和VC类似,内置了一些工程代码。在这里我们在RenderMonkey的工作区菜单里选择Add Effect -> DirectX->Screen-AlignedQuad. 在生成的工程中, RenderMonkey将显示一个默认的图片,首先我们就是要修改这个图片,双击那个base图片对应的节点,选择将要演示的图片。如下图。

接下来要开始进行关键的一步,编写处理图像的算法。双击刚才建立的项目中的single pass -> pixel shader . 开始编辑Pixel Shader的代码。
一个RGB颜色的亮度和各个分量之间的关系的公式为:
GrayValue = 0.3 * R + 0.59*G + 0.11 *B
根据这个公式,代码如下:

我来详细的解释一下这个Pixel Shader,首先是sampler2D Texture0。Texture0就是代表输入的图像。这个图像在RenderMonkey的工作区中用两部分表示,首先需要在工作区中创建一个图像对象bas,然后需要在用到这个纹理图像的pass中创建一个纹理对象Texture0,然后使Texture0指向刚才创建的纹理图像bas。读者应该注意到了纹理对象的名字就是Shader里的sampler2D变量的名字,RenderMonkey就是以这种方法把shader代码中的变量名字和工作区中的对象关联起来。同时float4/float3/float2/float变量都如此。 接下来的main函数中通过纹理采样的方式得到当前需要绘制的像素, float4 _inColor = tex2D( Texture0, texCoord ); 得到输入颜色后,可以通过上面给出的公式来计算出这个颜色的灰度值,并用这个值构造一个灰度颜色返回给Direct3D。系统就会把这个颜色作为最终的色彩显示在窗口中,也就是得到一个黑白的图像。最终结果如下图: 通过这个例子,我们已经基本了解了RenderMonkey处理图像的步骤和流程,下面我们通过分析一些更加复杂一点的例子来体会HLSL的强大能力。

l 入门效果之浮雕
"浮雕"图像效果是指图像的前景前向凸出背景。常见于一些纪念碑的雕刻上,要实现浮雕其实非常简单。把图像的一个象素和左上方的象素进行求差运算,并加上一个灰度。这个灰度就是表示背景颜色。这里设置这个插值为128 (图像RGB的值是0-255)。同时还应该把这两个颜色的差值转换为亮度信息,否则浮雕图像会出现彩色J
在使用HLSL处理浮雕效果的时候,两个问题我们需要注意一下。
其中一个是图像边界问题,写过类似实现的朋友都知道,在处理边界象素的时候可能是取不到左上角象素的。这个时候就应该做特殊处理,通常把边界位置的浮雕结果设置成背景颜色。但是使用HLSL的时候不需要在HLSL的shader中去对图像的边界做特殊处理,而需要对纹理设置滤波器, 这个滤波器设置为CLAMP模式就可以了。
第二个需要处理的问题是, 在PixelShader中纹理的采样坐标是0-1.0, 如果要取到左上纹理图像的大小,这样才能把一个象素的的偏移转换成0-1.0的值。假设纹理的大小是[w,h],当前纹理坐标是[u,v],那么图片左上角的象素的纹理坐标就是[u -1.0/w, v – 1.0/h]。RenderMonkey中无法知道这个纹理图像的大小。如果完全用VC++来实现的话,可以在加载图像或者从IDirect3DTexture9对象中得到纹理大小,然后当作一个constant常量设置给HLSL就可以了。或者假设纹理的大小就是1024 x 1024—得到的效果也是可以接受的.
现在展示一下用来得到浮雕效果的HLSL的代码:



原图 浮雕化后的图像
读者应该会发现相对于C++版本的代码HLSL的代码显得非常的干净利索,没有分支也没有循环。最重要的是它的速度非常快,对一个2048x2048的图像完全可以做到>30fps的实时处理能力并且不会耗费很多的CPU时间。
入门效果之马赛克 接下来我们完成一个更加常见的效果—马赛克。图片的马赛克就是把图片的一个相当大小的区域用同一个点的颜色来表示。可以认为是大规模的降低图像的分辨率,而让图像的一些细节隐藏起来。 用HLSL代码实现马赛克是非常简单的。但是同样的需要一些额外的步骤。 第一步就是先把纹理坐标转换成图像实际大小的整数坐标。接下来要把图像这个坐标量化---比如马赛克块的大小是8x8象素。那么我们可以用下列方法来得到马赛克后的图像采样值,假设[x.y]为图像的整数坐标: [x,y]mosaic = [ int(x/8)*8 , int(y/8)*8].
得到这个坐标后只要用相反的方法,把整数坐标转换回到0-1.0的纹理坐标。 具体的马赛克效果代码如下:

经过这个Shader处理后的图像结果如下:

图:马赛克处理效果
读者可能会发现这个马赛克太普通了,确实它不够新颖。下面我们来改良一下,我们希望达到这样一个效果:马赛克区域不是方的,而是圆的,圆形区域以外,依旧用图像原来的颜色覆盖。这样需要改变一下代码。 首先求出原来马赛克区域的正中心(原来是左上角):然后计算图像采样点到这个中心的距离,如果在马赛克圆内,就用区域的中心颜色,否则就用原来的颜色。改良后的代码如下,这里把马赛克区域大小调节成16x16。这样效果更明显。 这个代码相对上面的代码复杂了一些,加了一个分支if/else。注意,GPU是个高度并行的处理器,过多分支会降低Shader的运行速度。这个改良的马赛克效果如下 图: 改良后的马赛克效果


l 进阶效果之锐化模糊
以上两个效果相对比较简单,姑且称之为入门效果, 它并没有用到太多数字图像处理或者信号处理方面的知识。接下来我们要介绍稍微复杂一点的效果,第一个就是图像的模糊和锐化。
图像的模糊又成为图像的平滑(smoothing),人眼对高频成分是非常敏感的。如果在一个亮度连续变化的图像中,突然出现一个亮点,那么我们很容易察觉出来。类似的,如果图像有个突然的跳跃—明显的边缘,我们也是很容易察觉出来的。这些突然变化的分量就是图像的高频成分。人眼通常是通过低频成分来辨别轮廓,通过高频成分来感知细节的(这也是为什么照片分辨率低的时候,人们只能辨认出照片的大概轮廓,而看不到细节)。但是这些高频成分通常也包含了噪声成分。图像的平滑处理就是滤除图像的高频成分。
那么如何才能滤除图像的高频成分呢?先来介绍一下图像数字滤波器的概念。
简单通俗的来说,图像的数字滤波器其实就是一个n *n的数组(数组中的元素成为滤波器的系数或者滤波器的权重,n称为滤波器的阶)。对图像做滤波的时候,把某个像素为中心的n*n个像素的值和这个滤波器做卷积运算(也就是对应位置上的像素和对应位置上的权重的乘积累加起来),公式如下
其中x , y 为当前正在处理的像素坐标。
通常情况下,滤波器的阶数为3已经足够了,用于模糊处理的3x3滤波器如下

经过这样的滤波器,其实就是等效于把一个像素和周围8个像素一起求平均值,这是非常合理的---等于把一个像素和周围几个像素搅拌在一起—自然就模糊了J
用来对一个图像做滤波处理的函数如下:


剩下的工作就是定义一个用来进行模糊的滤波器模板,并调用dip_filter这个函数就可以了。代码如下:

以上的模糊滤波器称为BOX滤波器,是最简单的滤波器,如果考虑到离开中心像素的距离对滤波器系数的影响,我们通常采用更加合理的滤波器---高斯滤波器—一种通过2维高斯采样得到的滤波器,它的模板如下:

很容易看出来,离开中心越远的像素,权重系数越小。
对于锐化操作,常用的锐化模板是拉普拉斯(Laplacian)模板,这个模板定义如下: 容易看出拉普拉斯模板的作法:先将自身与周围的8个象素相减,表示自身与周围象素的差别;再将这个差别加上自身作为新象素的灰度。如果一片暗区出现了一个亮点,那么锐化处理的结果是这个亮点变得更亮,这就增强了图像的细节。
下面三副图分别表示了经过BOX滤波。高斯滤波和拉普拉斯滤波后的图像
BOX 模糊 高斯模糊 拉普拉斯锐化
高斯模糊和拉普拉斯锐化效果的HLSL和BOX的代码基本一致,就是filter的系数不同,这里不在列出。
通过这个两个效果,我们介绍了图像的滤波操作,这样的操作,也称为模板操作。它实现了一种邻域运算(Neighborhood Operation),即某个象素点的结果灰度不仅和该象素灰度有关,而且和其邻域点的值有关。模板运算在图像处理中经常要用到,可以看出这是一项非常耗时的运算。有一种优化的方法称为可分离式滤波,就是使用两个pass来进行x/y方向分别滤波,能让运算次数大大减少。而且滤波器阶数越高,优势越明显。
数字图像滤波的时候,同样还需要注意边界像素的问题,不过幸好,HLSL能让边界处理更加的透明和简单。




l 进阶效果之描边效果
相对浮雕效果来说,描边(边缘检测)的代码并不复杂多少,只是在理论上相对来说稍微复杂一点,而且效果看上去更加的讨人喜欢一些。
如果在图像的边缘处,灰度值肯定经过一个跳跃,我们可以计算出这个跳跃,并对这个值进行一些处理,来得到边缘浓黑的描边效果。
首先考虑对这个象素的左右两个象素进行差值,得到一个差量,这个差量越大,表示图像越处于边缘,而且这个边缘应该左右方向的。同样我们能得到上下方向和两个对角线上的图像边缘。这样我们需要构造一个滤波器

经过这个滤波器后得到的是图像在这个象素处的变化差值,把它转化成灰度值并求绝对值(差值可能为负),然后我们定义差值的绝对值越大的地方越黑(边缘显然是黑的),否则越白,可以得到如下的效果:

该效果的代码如下(其中dip_filter函数代码同上):

上面演示的效果种用到的模板就是一种边缘检测器,在信号处理上是一种基于梯度的滤波器,又称边缘算子。梯度是有方向的,和边沿的方向总是正交(垂直)的,在上面的代码中,我们采用的就是一个梯度为45度方向模板,它可以检测出135度方向的边沿。
以上是简单的边缘检测算子。更加严格的,我们可以采样Sobel算子。Sobel 算子有两个,一个是检测水平边沿的
,另一个是检测垂直平边沿的
同样,Sobel算子另一种形式是各向同性Sobel算子,也有两个,一个是检测水平边沿的
,另一个是检测垂直边沿的
。各向同性Sobel算子和普通Sobel算子相比,它的位置加权系数更为准确,在检测不同方向的边沿时梯度的幅度一致。读者可以自行尝试Sobel算子的效果,只要修改pencil_filter的值就可以了。
l 高级效果之伪 HDR/Blow
所谓HDR就是高动态范围的意思。在普通的显示器和位图里,每通道都是8-bit,也就是说RGB分量的范围都是0-255,这用来表示现实中的颜色显然是远远不够的。现实中的图像的动态范围远远大的多,那么如何在现有的显示设备里尽可能的保持更大的动态范围,而且让它能更符合人眼的习惯就成了图形学研究的一个热点。通常真正的HDR的做法都是采用浮点纹理,把渲染运算的过程中,我们使用16bit的动态范围来保存运算结果,然后对运算结果进行分析,求出这个图像的中间灰度值,然后对图像进行调整映射到LDR的设备中。但是这样的算法有两个非常耗资源的过程,其中一个是浮点纹理,另外一个就是求图像中间灰度(通常情况是把图像不停的渲染到RenderTarget,每渲染一次,图像大小缩小一半,直到缩小到1x1大,一个1024 x1024的图像需要渲染10次!)。因此虽然HDR的效果非常漂亮,但是目前还是只有为数不多的产品采用了这样的算法,大部分都是采用的伪HDR+blow效果。
伪HDR效果通常是重新调整图像的亮度曲线,让亮的更亮,暗的更暗一些,而Blow效果则是图像的亮度扩散开来,产生柔和的效果。
在这里采用一个二次曲线来重新调整图像的亮度,这个曲线的方程是
x [ (2-4k) x + 4k-1 ).
K的取值范围为0.5 – 2.0
经过这个公式调整以后,图像上亮的区域将更加的亮,并且整体亮度会提高。那么接下来,如何使图像的亮度扩散开来呢?一种可行的方法就是对场景图像做一次downsample。把它变成原来的1/4次大小,那样就等于亮度往外扩散了4x4个象素的区域。
技术方案已经基本有了。接下来将要在RenderMonkey里实现它,和前面的例子不同,这里需要使用RenderMonkey的多pass渲染:
第一个pass我们先对图像进行downsample操作。首先在原来的pass前增加一个新的pass,并命名为downsample pass。然后建立一个render to texture的纹理节点,设定它的大小(原图像大小的1/4),并在新建立的pass里建立一个render target的节点指向它。这样,这个pass的渲染结果就会保存到这个render to texture中。这个pass的pixel shader只要使用前面模糊效果的shader就可以了。
接下来,将另外一个pass命名为render pass,并增加一个新的纹理对象。并把纹理对象指向刚才增加的render to texture,以便能在该pass中使用这个downsample过的图像。然后增加一个float类型的变量,用来表示上面公式中提到的k值;最终工作区的结构如下图:

接下来编辑render pass的pixel shader。代码如下:


下面是原图像和经过处理后图像的对比:



原图 k = 1.1 k = 1.6
图:经过伪HDR+Blow处理过的图像和原图的对比