Direct3D提高篇之:HLSL编程实现PhotoShop滤镜效果
潘李亮 2007-3-16
xheartblue@163.com
关于学习,中国有句古话叫“学以致用”,可见把学到的东西用于实际实践中是多么的重要,现在学习Direct3D/HLSL的人非常多,教程也非常多。但是很多人不知道看完这些教程后该干什么,或者说可以怎么利用学到的知识,本文针对已经学习过Direct3D/HLSL的初学者,讲述如果将HLSL用于数字图像处理,带领大家一起体会HLSL的强大。
本文会对Direct3D/HLSL做一个简单的介绍,但是假设读者已经了解和掌握了Direct3D/HLSL的基本知识。
简介.
1)Direct3D和HLSL
众所周知,Direct3D是微软开发的用于编写Windows下高性能图形程序的3D API。通过Direct3D,我们可以访问高速的图形加速卡。它是DirectX众多成员的一部分。
HLSL 全称High Level Shading Language . 是MS推出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的教程,如果读者觉得以上的概念还比较陌生,请先学习Direct3D的基础知识。同时关于如何在Direct3D应用程序中使用HLSL编写的Vertex Shader和Pixel Shader,请参阅其它的教程和微软的DirectX SDK。
2)RenderMonkey简介
现在的开发人员可能都比较熟悉IDE的工作模式,尤其是使用Visual Studio一类开发工具的Windows程序开发人员。在一个统一的开发环境中,可以编写和调试程序。HLSL作为一种新的语言,GPU编程作为一种新事物,目前还没有很好的IDE能完整的支持编写,调试一体化的工作方式。在本文我们将使用ATI的一个相对比较好用的开发HLSL的IDE: RenderMonkey。
RenderMonkey是由前ATI开发的,用于编写Shader,并调试Shader的一个工具。由于RenderMonkey支持插件,所以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的代码编辑器中进行编辑,并调用命令来编译。
因为文章篇幅的关系,也不采用编写Direct3D程序加载HLSL的方式来做演示程序,而是直接使用RenderMonkey来作为演示的平台。关于如何使用RenderMonkey,请参照RenderMonkey的帮助,或者打开RenderMonkey自带的例子,很容易就能掌握这个工具的使用方法。
GPGPU
本文将要介绍的是如何用HLSL来实现PhotoShop的滤镜效果,也就是说需要通过GPU来进行数字图像处理。这是目前很流行的GPGPU的应用的一种。
我们知道,GPU和CPU的工作方式和用途都是不同的,CPU是通用的处理器,而GPU是专用于处理3D图形显示的,因此CPU的指令集更加丰富,而GPU的指令集更加有针对性,因此这就决定了GPU在牺牲了CPU的灵活性的前提上有更快的运行速度。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
根据这个公式,我们的代码如下:
sampler2D Texture0; float4 main( float2 texCoord : TEXCOORD0 ) : COLOR { float4 _inColor = tex2D( Texture0, texCoord ); float h = 0.3 * _inColor.x + 0.59 * _inColor.y + 0.11* _inColor.z; float4 _outColor = float4(h,h,h,1.0); return _outColor; }
我来详细的解释一下这个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。系统就会把这个颜色作为最终的色彩显示在窗口中,也就是得到一个黑白的图像。最终结果如下图:
图:图像去色效果
(注:这个例子是最简单的HLSL用于图像处理的例子,如果读者觉得到目前为止还很有难度,建议重新温习一遍Direct3D和HLSL的知识)。
通过这个例子,我们已经基本了解了RenderMonkey处理图像的步骤和流程,下面我们通过分析一些更加复杂一点的例子来体会HLSL的强大能力
l 入门效果之浮雕
"浮雕"图象效果是指图像的前景前向凸出背景。常见于一些纪念碑的雕刻上,要实现浮雕其实非常简单。我们把图象的一个象素和左上方的象素进行求差运算,并加上一个灰度。这个灰度就是表示背景颜色。这里我们设置这个插值为128 (图象RGB的值是0-255)。同时,我们还应该把这两个颜色的差值转换为亮度信息.否则浮雕图像会出现彩色J
在使用HLSL处理浮雕效果的时候,两个问题我们需要注意一下。
其中一个图象边界,写过C++实现浮雕效果的朋友都知道,在处理边界象素的时候可能是取不到左上角象素的, 这个时候就应该左做特殊处理,通常我们把边界位置的浮雕结果设置成背景颜色,但是使用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的代码:
sampler2D Texture0; float2 TexSize; float4 main( float2 texCoord : TEXCOORD0 ) : COLOR { float2 upLeftUV = float2(texCoord.x - 1.0/TexSize.x , texCoord.y - 1.0/TexSize.y); float4 bkColor = float4(0.5 , 0.5 , 0.5 , 1.0); float4 curColor = tex2D( Texture0, texCoord ); float4 upLeftColor = tex2D( Texture0, upLeftUV ); //相减得到颜色的差 float4 delColor = curColor - upLeftColor; //需要把这个颜色的差设置 float h = 0.3 * delColor.x + 0.59 * delColor.y + 0.11* delColor.z; float4 _outColor = float4(h,h,h,0.0)+ bkColor; return _outColor; }
原图 浮雕化后的图像
读者应该会发现,相对于C++版本的代码,HLSL的代码显得非常的干净和利索.没有分支,没有循环.最重要的是它的速度非常快,对一个2048x2048的图像完全可以做到>30fps的实时处理能力而不会耗费很多的CPU时间!
l 入门效果之马赛克
接下来我们完成一个更加常见的效果—马赛克.图片的马赛克就是把图片的一个相当大小的区域用同一个点的颜色来表示.可以认为是大规模的降低图像的分辨率,而让图像的一些细节隐藏起来, 比如电视中要秀一下某个罪犯的身材,却又不能展示他的脸,这个时候我们就可以给他的脸加一个马赛克.
用HLSL代码实现马赛克是非常简单的,但是同样的,我们需要一些额外的步骤,第一步就是先把纹理坐标转换成图像实际大小的整数坐标.接下来,我们要把图像这个坐标量化---比如马赛克块的大小是8x8象素。那么我们可以用下列方法来得到马赛克后的图像采样值,假设[x.y]为图像的整数坐标:
[x,y]mosaic = [ int(x/8)*8 , int(y/8)*8].
得到这个坐标后,我们只要用相反的方法,把整数坐标转换回到0-1.0的纹理坐标。
具体的马赛克效果代码如下:
sampler2D Texture0; float2 TexSize; float2 mosaicSize = float2(8,8); float4 main( float2 texCoord : TEXCOORD0 ) : COLOR { //得到当前纹理坐标相对图像大小整数值。 float2 intXY = float2(texCoord.x * TexSize.x , texCoord.y * TexSize.y); //根据马赛克块大小进行取整。 float2 XYMosaic = float2(int(intXY.x/mosaicSize.x) * mosaicSize.x, int(intXY.y/mosaicSize.y) * mosaicSize.y ); //把整数坐标转换回纹理采样坐标 float2 UVMosaic = float2(XYMosaic.x/TexSize.x , XYMosaic.y/TexSize.y); return tex2D( Texture0, UVMosaic ); }
经过这个Shader处理后的图像结果如下:
图:马赛克处理效果
读者可能会发现这个马赛克太普通了,确实它不够新颖,下面我们来改良一下,我们希望达到这样一个效果:马赛克区域不是方的,而是圆的,圆形区域以外,我们用图像原来的颜色覆盖。这样我们需要改变一下代码。
首先求出原来马赛克区域的正中心(原来是左上角):然后计算图像采样点到这个中心的距离,如果在马赛克圆内,就用区域的中心颜色,否则就用原来的颜色。改良后的代码如下,这里我们把马赛克区域大小调节成16x16。这样效果更明显。
sampler2D Texture0; float2 TexSize; float2 mosaicSize = float2(16,16); float4 ps_main( float2 texCoord : TEXCOORD0 ) : COLOR { float2 intXY = float2(texCoord.x * TexSize.x , texCoord.y * TexSize.y); //马赛克中心不再是左上角,而是中心 float2 XYMosaic = float2(int(intXY.x/mosaicSize.x) * mosaicSize.x, int(intXY.y/mosaicSize.y) * mosaicSize.y ) + 0.5 * mosaicSize; //求出采样点到马赛克中心的距离 float2 delXY = XYMosaic - intXY; float delL = length(delXY); float2 UVMosaic = float2(XYMosaic.x/TexSize.x , XYMosaic.y/TexSize.y); float4 _finalColor; //判断是不是处于马赛克圆中。 if(delL< 0.5 * mosaicSize.x) _finalColor = tex2D( Texture0, UVMosaic ); else _finalColor = tex2D( Texture0, texCoord ); return _finalColor; }
这个代码相对上面的代码复杂了一些,加了一个分支if/else。注意,GPU是个高度并行的处理器,过多分支会降低Shader的运行速度。这个改良的马赛克效果如下
图: 改良后的马赛克效果
l 进阶效果之锐化模糊
以上两个效果相对比较简单,姑且称之为入门效果, 它并没有用到太多数字图像处理或者信号处理方面的知识。接下来我们要介绍稍微复杂一点的效果,第一个就是图像的模糊和锐化。
图像的模糊又成为图像的平滑(smoothing),我们知道人眼对高频成分是非常敏感的,如果在一个亮度连续变化的图像中,突然出现一个亮点,那么我们很容易察觉出来,类似的,如果图像有个突然的跳跃—明显的边缘,我们也是很容易察觉出来的。这些突然变化的分量就是图像的高频成分。人眼通常是通过低频成分来辨别轮廓,通过高频成分来感知细节的(这也是为什么照片分辨率低的时候,人们只能辨认出照片的大概轮廓,而看不到细节)。但是这些高频成分通常也包含了噪声成分。图像的平滑处理就是滤除图像的高频成分。
那么如何才能滤除图像的高频成分呢?我们先来介绍一下图像数字滤波器的概念。
简单通俗的来说,图像的数字滤波器其实就是一个n x n的数组(数组中的元素成为滤波器的系数或者滤波器的权重,n称为滤波器的阶)。对图像做滤波的时候,把某个像素为中心的nxn个像素的值和这个滤波器做卷积运算(也就是对应位置上的像素和对应位置上的权重的乘积累加起来),公式如下
其中x , y 为当前正在处理的像素坐标。
通常情况下,我们滤波器的阶数为3已经足够了,用于模糊处理的3x3滤波器如下
。
经过这样的滤波器,其实就是等效于把一个像素和周围8个像素一起求平均值,这是非常合理的---等于把一个像素和周围几个像素搅拌在一起—自然就模糊了J
用来对一个图像做滤波处理的函数如下:
//用来做滤波操作的函数 float4 dip_filter(float3x3 _filter , sampler2D _image, float2 _xy, float2 texSize) { //纹理坐标采样的偏移 float2 _filter_pos_delta[3][3] = { { float2(-1.0 , -1.0) , float2(0,-1.0), float2(1.0 , -1.0) }, { float2( 0.0 , -1.0) , float2(0, 0.0), float2(1.0 , 0.0) }, { float2( 1.0 , -1.0) , float2(0, 1.0), float2(1.0 , 1.0) }, }; //最终的输出颜色 float4 final_color = float4(0.0,0.0,0.0,0.0); //对图像做滤波操作 for(int i = 0 ; i < 3 ; i ++ ) { for(int j = 0 ; j < 3 ; j ++) { //计算采样点,得到当前像素附近的像素的坐标 float2 _xy_new = float2(_xy.x + _filter_pos_delta[i][j].x , _xy.y + _filter_pos_delta[i][j].y); float2 _uv_new = float2(_xy_new.x/texSize.x , _xy_new.y/texSize.y); //采样并乘以滤波器权重,然后累加 final_color += tex2D( _image, _uv_new ) * _filter[i][j]; } } return final_color; }
剩下的工作,我们就是定义一个用来进行模糊的滤波器模板,并调用dip_filter这个函数就可以了。代码如下:
sampler2D Texture0; float2 TexSize; float4 main( float2 texCoord : TEXCOORD0 ) : COLOR { float2 intXY = float2(texCoord.x * TexSize.x , texCoord.y * TexSize.y); //用于模糊操作的滤波器 float3x3 _smooth_fil = float3x3 (1/9.0 ,1/9.0,1/9.0 , 1/9.0 ,1/9.0,1/9.0 , 1/9.0 ,1/9.0,1/9.0 ); return dip_filter(_smooth_fil , Texture0 , intXY, TexSize); }
以上的模糊滤波器称为BOX滤波器,是最简单的滤波器,如果考虑到离开中心像素的距离对滤波器系数的影响,我们通常采用更加合理的滤波器---高斯滤波器—一种通过2维高斯采样得到的滤波器,它的模板如下:
很容易看出来,离开中心越远的像素,权重系数越小。
对于锐化操作,常用的锐化模板是拉普拉斯(Laplacian)模板,这个模板定义如下:
容易看出拉普拉斯模板的作法:先将自身与周围的8个象素相减,表示自身与周围象素的差别;再将这个差别加上自身作为新象素的灰度。可见,如果一片暗区出现了一个亮点,那么锐化处理的结果是这个亮点变得更亮,这就增强了图像的细节。
下面三副图分别表示了经过BOX滤波。高斯滤波和拉普拉斯滤波后的图像
BOX 模糊 高斯模糊 拉普拉斯锐化
高斯模糊和拉普拉斯锐化效果的HLSL和BOX的代码基本一致,就是filter的系数不同,这里不在列出。
通过这个两个效果,我们介绍了图像的滤波操作,这样的操作,也成为模板操作,它实现了一种邻域运算(Neighborhood Operation),即某个象素点的结果灰度不仅和该象素灰度有关,而且和其邻域点的值有关。模板运算在图象处理中经常要用到,可以看出,它是一项非常耗时的运算。有一种优化的方法称为可分离式滤波,就是使用两个pass来进行x/y方向分别滤波,能让运算次数大大减少。而且滤波器阶数越高,优势越明显。
数字图像滤波的时候,同样还需要注意边界像素的问题,不过幸好,HLSL能让边界处理更加的透明和简单。
l 进阶效果之描边效果
相对浮雕效果来说,描边(边缘检测)的代码并不复杂多少,只是在理论上相对来说稍微复杂一点,而且效果看上去更加的讨人喜欢一些。
我们知道 ,如果在图像的边缘处,灰度值肯定经过一个跳跃,我们可以计算出这个跳跃,并对这个值进行一些处理,来得到边缘浓黑的描边效果。
首先我们可以考虑对这个象素的左右两个象素进行差值,得到一个差量,这个差量越大,表示图像越处于边缘,而且这个边缘应该左右方向的,同样我们能得到上下方向和两个对角线上的图像边缘。这样我们构造一个滤波器
经过这个滤波器后,我们得到的是图像在这个象素处的变化差值,我们把它转化成灰度值,并求绝对值(差值可能为负),然后我们定义差值的绝对值越大的地方越黑(边缘显然是黑的),否则越白,我们便得到如下的效果:
图:铅笔描边效果
该效果的代码如下(其中dip_filter函数代码同上):
sampler2D Texture0; float2 TexSize; float4 main( float2 texCoord : TEXCOORD0 ) : COLOR { float2 intXY = float2(texCoord.x * TexSize.x , texCoord.y * TexSize.y); float3x3 _pencil_fil = float3x3 (-0.5 ,-1.0 , 0.0 , -1.0 , 0.0 , 1.0 , -0.0 , 1.0 , 0.5 ); float4 delColor = dip_filter(_pencil_fil , Texture0 , intXY, TexSize); float deltaGray = 0.3 * delColor.x + 0.59 * delColor.y + 0.11* delColor.z; if(deltaGray < 0.0) deltaGray = -1.0 * deltaGray; deltaGray = 1.0 - deltaGray; return float4(deltaGray,deltaGray,deltaGray,1.0); }
上面演示的效果种用到的模板就是一种边缘检测器,在信号处理上是一种基于梯度的滤波器,又称边缘算子,梯度是有方向的,和边沿的方向总是正交(垂直)的,在上面的代码中,我们采用的就是一个梯度为45度方向模板,它可以检测出135度方向的边沿。
以上是简单的边缘检测算子,更加严格的,我们可以采样Sobel算子,Sobel 算子有两个,一个是检测水平边沿的 ,另一个是检测垂直平边沿的 ,同样,Sobel算子另一种形式是各向同性Sobel算子,也有两个,一个是检测水平边沿的 ,另一个是检测垂直边沿的 。各向同性Sobel算子和普通Sobel算子相比,它的位置加权系数更为准确,在检测不同方向的边沿时梯度的幅度一致。读者可以自行尝试Sobel算子的效果,只要修改pencil_filter的值就可以了。
l 高级效果之伪 HDR/Blow
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。代码如下:
sampler2D Texture0; sampler2D TexDownSample; float k ;//控制参数,公式中k值. float4 xposure(float4 _color , float gray , float ex) {//重新调整场景的亮度 float b = ( 4 * ex - 1 ); float a = 1 - b; float f = gray * ( a * gray + b ); return f * _color; } float4 main( float2 texCoord : TEXCOORD0 ) : COLOR { //亮度信息从downSample后的图像中获得 float4 _dsColor = tex2D(TexDownSample , texCoord); float _lum = 0.3 * _dsColor.x + 0.59 * _dsColor.y + 0.11* _dsColor.z; float4 _fColor = tex2D(Texture0 , texCoord); //对最终颜色进行修正 return xposure(_fColor , _lum , k); }
下面是原图像和经过处理后图像的对比:
原图 k = 1.1 k = 1.6
图:经过伪HDR+Blow处理过的图像和原图的对比
l 高级效果之水彩化
真正的水彩效果在shader中是比较难实现的,它需要进行中值滤波后累加等一些操作,还需要处理NPR中的笔触一类的概念。本文绕开这些概念,只从视觉效果上能尽量模拟出水彩的画的那种感觉来。
我们知道,水彩画一个最大的特点是水彩在纸上流动扩散后会和周围的颜色搅拌在一起,另外一个特点就是水彩通常会形成一个个的色块,过渡不像照片那样的平滑。针对这两个特点。我们可以设计这样的一个算法来模拟水彩画的效果。
首先我们模拟扩散,简单的说,可以通过随机对附近的象素点进行采样来模拟颜色的扩散,而这个随机区域的大小我们可以称为扩散的力度。这在C++代码里应该是非常容易实现的,读者只需要使用Random函数就可以了。但是HLSL并没有提供这样的函数(似乎有个noise函数,不过不能用L)。怎么办呢?我们可以采用噪声纹理的方式,既事先计算好一个nxn的随机数数组,作为纹理传递给Pixel shader,这样在Pixel Shader里我们就能获得随机数了。得到随机数后,我们将随机数映射成纹理坐标的偏移值,就能模拟出色彩的扩散了。典型的噪声纹理是这个样子的:
图:噪声纹理
接下来我们需要处理色块,我们对颜色的RGB值分别进行量化,把RGB分量由原来的8bit量化成比特数更低的值。这样颜色的过渡就会显得不那么的平滑,而是会呈现出一定的色块效果。
通过以上两步处理后,我们得到的图像依然有非常多的细节,尤其是第一步处理中产生的很多细节噪点,很自然的我们就想到通过平滑模糊的方式来过滤掉这些高频噪声成分。
算法设计好了,接下来看看我们如何在RenderMonkey里实现这个算法。
类似上一个效果,我们需要两个pass来完成这个算法,第一个pass叫flow pass,模拟颜色的流动和处理颜色的量化。第二个pass叫Gauss pass,也就是前面提到的高斯模糊算法。我们的重点在第一个pass。
在模拟扩散的pass中,我们同样需要一个RenderTarget,以把结果保存在其中以便后续处理,然后还需要一个噪声纹理来产生随机数。具体代码如下:
sampler2D Texture0; sampler2D noiseTexture; float _quatLevel ; //量化的bit数 ,取值2-6比较适合 float _waterPower; //表示扩展力度,单位为象素 float4 quant(float4 _cl , float n) {//该函数对颜色的四个分量进行量化 _cl.x = int(_cl.x * 255 / n) * n /255; _cl.y = int(_cl.y * 255 / n) * n /255; _cl.z = int(_cl.z * 255 / n) * n /255; return _cl; } float4 main( float2 texCoord : TEXCOORD0 ) : COLOR { //取得随机数,对纹理坐标进行扰动,形成扩散效果 float4 noiseColor = _waterPower * tex2D(noiseTexture , texCoord); float2 newUV = float2(texCoord.x + noiseColor.x / TexSize.x , texCoord.y + noiseColor.y / TexSize.y); float4 _fColor = tex2D(Texture0 , newUV); //量化图像的颜色值,形成色块 return quant(_fColor , 255/pow(2, _quatLevel) ); }
代码中的_quatLevel用来表示对图像的量化比特数,值越小,色块越明显,比较合理的取值范围是2-6。_waterPower则表示图像颜色扩散范围,取值范围在8-64之间的效果比较好。
下面是经过水彩画处理后的图像:
图:水彩画效果。左图量化比特数为6比特,扩散范围为20象素。
右图量化比特数为5比特,扩散范围为40象素
l 总结
GPU进行数字图像处理,甚至是使用GPU进行数字视频编辑是目前非常流行的话题,市场是已经出现很多商业的产品,比如Mac公司的iMotion,就是完全采用GPU加速的视频非编软件,iMotion作者的对它的评价是:Play with the images in real-time。可见它的效率之高,本文只是简单的介绍了HLSL在图像处理领域的应用,希望能给没入门的读者拨开一些云雾。通过以上介绍的几种滤镜效果,读者应该大致掌握了使用HLSL进行数字图像处理的一些基本步骤和方法了,为了方便起见,我们并没有把处理完的图像保存下来而是仅仅把处理完的图像显示在屏幕上,其实在RendererMonkey中也是可以把处理完的结果保存起来的,我们可以创建一个和图像等大的RenderTarget。并把我们处理的结果绘制到这个RenderTarget中(关于如何设置当前的RenderTarget,以及如何设置多个RenderTarget,留给读者自己摸索),然后在RenderMonkey的工作区中选择那个RenderTarget,在右键菜单中选择保存到图像就可以了。
我们知道C++也好,Basic也好,乃至现在的HLSL/GLSL也好。它们都是语言而已,充分的了解这些语言,熟悉他们的特性都是非常简单的。但是如何充分发挥他们的作用,用它们做一些有意义的事情,就完全在于我们自己的实践和在实践中的创造性。如果读者在实践中还能创造处更多,更实用的效果。甚至是应用的商业产品中。
最后还得提一下的是,文中出现不少信号处理和数学的知识,可见多花点时间在数学上是非常值得的J
注:处于阅读方便,本文代码都未经过优化。
参考资料:
高等教育出版社《数字图像处理》
RenderMonkey官方网站:http://ati.amd.com/developer/rendermonkey/index.html