DirectX12(D3D12)基礎教程(十一)——幾個“上古時代”的基於Pixel Shader的濾鏡效果

1、前言

記得大約是在10多年前,還在我努力的在學習着DirectX編程和3D引擎編程的方方面面知識的時候,初次接觸到了當時很先進的Shader程序。當時蒐集了很多資料,其中有一份就是講解如何在PixelShader中實現類似PhotoShop中的一些濾鏡功能。

這次我就將這些陳年的有意思的Shader翻出來,用D3D12再次回爐一下,也做成教程,供大家學習Shader使用。

本章的源代碼已經全部上傳到GitHub上,大家可以下載參考學習。

從本章開始,教程的重心將轉移到Shader(HLSL)的編寫等方面上來,因爲我們已經學習了包括實時光追渲染(DXR)的基本編程方法在內的很多C++側編程調用D3D12接口的技能了,整個教程作爲D3D12接口的基本使用方法來說,這部分學習基本可以暫告一個段落了。接着我們就進入Shader編程的部分,因爲Shader纔是驅動GPU實現“光影魔法”的真正技能!(注:可能重點將放在Raytracing Shader方面,因爲DXR自帶例子中這部分的Shader實在是太噁心了,而且被搞成了光柵化渲染Shader的附庸,這完全是本末倒置,大家根本體會不到Raytracing Shader的真正魅力。)

本章源碼基於之前的多線程渲染的示例,原始運行效果如下:

 

注意圖中爲了較好的顯示本章Pixel Shader濾鏡效果,所以球體的紋理換成了地球的紋理,這樣在視覺效果上更明顯一些。

以後傳統光柵化的示例代碼,都將使用多線程渲染的示例來作爲基礎框架,這樣做主要的目的:一是爲了督促各位認真學習系列教程打牢基礎;二是爲了讓大家牢固掌握並且習慣多線程的基本編程方法;三是讓大家在習慣多線程渲染的基礎上能夠產生一些有“靈感”的想法,儘快從基礎學習的“必然王國”跨越到可以隨心所欲發揮創造力的“自由王國”。未來一定是多線程渲染的天下!

2、Shader中的Static變量

爲了實現本次教程中的Pixel Shader特效,在Shader中加入了很多參變量,

在此提醒大家:凡是在Shader中(不論什麼Shader)的全局變量,全是渲染管線的參數,都需要在C++代碼側的根簽名中加以描述,當然唯一的例外就是用static類型修飾符定義的變量。

當Shader中的變量被聲明爲static型時,那麼該變量就僅用於Shader代碼內部,也不用聲明在根簽名中,這樣就不必再從C++代碼中經過複雜的過程傳入Shader中。

在本章示例Shader中,主要是聲明瞭一個指定馬賽克效果像素半徑範圍的變量。當然這個變量也可以不聲明爲static型,這樣就需要定義對應的根簽名中的參數,然後從C++代碼中傳入。如果再爲這個變量加上輸入控制,那麼就可以看到馬賽克效果中馬賽克大小的變化。各位可以作爲練習自行修改一下試試。

這幾個變量的聲明如下:

static float2 g_v2MosaicSize1 = float2(32.0f, 32.0f);
static float2 g_v2MosaicSize2 = float2(16.0f, 16.0f);

3、通過主線程上傳噪聲紋理

之前的多線程渲染示例中,主線程其實並沒有做什麼資源加載複製等等的工作,當然這是例子中將不同的物體放到不同的線程中渲染必然的一個設計實現要求,因爲相關的一些數據還是掌握在各自線程手中比較好。

其中有一個沒有交代的細節就是關於MVP矩陣的那個全局const buffer,是每個線程都單獨創建了一份,並且每次都通過同一個根簽名的參數通道上傳到渲染管線(GPU),這樣每個物體不同的MVP就被不同的對應線程以同樣的方式進行處理,這是一個重要的技巧。

然鵝,有一些資源是場景共用的,分配到子線程去渲染處理顯然不太合適,因爲每個子線程都可能需要使用這類資源,這樣就會多出一些額外跨線程共享資源的操作,使問題變得複雜,幸運的是,我們還有主線程可以用來幹這些全場景的活計,在本例中,因爲效果需要我們要加載一個噪聲紋理,而這個噪聲紋理就是每個物體渲染都需要的,所以我們就是用主線程來加載它。主線程中有很多命令列表可以使用,示例中我們就使用pICmdListPre命令列表來加載噪聲紋理,具體代碼就不粘貼了,大家去GitHub上查看即可。這樣實際最終的效果就是幾個子線程+主線程完成了紋理加載的第一個Copy動作,然後GPU上的複製引擎則完成了第二個Copy動作。

如果想做進一步優化,就可以爲每個線程創建獨立的Copy命令隊列(GPU上往往有多個Copy引擎),然後創建專門的Copy命令列表來執行資源上傳的第二個Copy動作。當然這裏的優化建議沒有提及因爲多個線程其實還是需要從硬盤讀取初始的資源數據,而這時就不得不跟低速的IO操作打交道了,如果你看過我之前寫的一些關於IOCP的文章,那麼就可以考慮將渲染子線程改造成可以支持IOCP回調的形式,也就是線程池形式,從而從頭到尾最高效的利用所有的系統硬件完成資源上傳GPU的動作,也就是加速場景加載的過程。

Ok,這個話題就扯到這裏吧,資料我的博客裏都有了,剩下的就是各位自己動手豐衣足食了!

4、黑白效果

本次,我們需要實現的第一個PS特效就是圖像黑白化。原理就是像素RGB顏色的亮度和各個顏色分量之間的關係的公式:

GrayValue = 0.3 * R + 0.59 * G  + 0.11 * B

根據這個公式,我們將RGB顏色直接轉換爲其亮度值,並將亮度值重新設置到每個顏色分量,就變成了該像素的灰度顏色,最終整個紋理中的像素都經過這個變換就變成了一張黑白照片:

 

對應的PS代碼很簡單,如下:

float4 BlackAndWhitePhoto(float4 inColor)
{
    float BWColor = 0.3f * inColor.x + 0.59f * inColor.y + 0.11f * inColor.z;
    return float4(BWColor, BWColor, BWColor, 1.0f);
}

在最終的PSMain函數中,我們如下調用這個效果函數即可:

float4 PSMain(PSInput input) : SV_TARGET
{
    float4 c4PixelColor;
    ……
    else if( 1 == g_nFun )
    {//黑白效果
        c4PixelColor = g_texture.Sample(g_sampler, input.m_v2UV);
        c4PixelColor = BlackAndWhitePhoto(c4PixelColor);
    }
    ……
    return c4PixelColor;
}

代碼中,只是簡單的取得像素點對應紋理的顏色值,然後利用我們的黑白特效函數計算成黑白顏色值之後返回即可。

其實最終無論PSMain函數中計算過程如何複雜,結果只是爲了返回屏幕上一個像素點的顏色而已,是爲“着色”,這也是Shader程序被稱爲“着色器”的緣由。

這裏需要注意的就是,爲了演示不同的效果,專門設計了一個全局變量來進行控制,因此最終PSMain函數中就會出現一個巨大的if……else if……else……的判斷語句,其實這在CPU的代碼上不會帶來什麼大問題,但是對於GPU來說,因爲它在條件分支語句硬件支持上的限制,所以這樣的結構對於GPU來說是非常影響效率的。在正式的Shader程序中應當避免。因爲我比較懶,不想創建那麼多PSO或者使用囉嗦的dynamic linker技術,所以就這樣寫咯,各位看官一定要注意,這個if……else……不在學習範圍內。

那麼實際的Shader中,可以使用Shader的運行時linker技術(DX中叫做HLSL的動態鏈接技術,自DX11引入),或切換不同Shader渲染管線的方式來避免這種情況。

另外在我們的例子中,大家要注意一個“後處理”問題,那就是我們可以看到實際的渲染結果更像是對單獨某個物體的紋理做了濾鏡效果,而不是整個渲染畫面,至少天空的背景色還是保留的。大家可以想象一下其實這類濾鏡也可以運用到最終整個渲染結果畫面上,這種情況通常被稱爲“後處理”(Post Shader),大家可以思考下怎麼修改可以實現這個後處理?這個問題就留給大家自己動手了,有疑問的可以隨時留言垂詢。

5、浮雕效果

"浮雕"圖像效果是指圖像的前景前向“凸出”背景。常見於一些紀念碑的雕刻上,要實現浮雕其實非常簡單。把圖像的一個象素和其左上方的象素進行求差運算,並加上一個灰度。這個灰度就是表示背景顏色。這裏設置這個插值爲128 (圖像RGB的值是0-255)。同時還應該把這兩個顏色的差值轉換爲亮度信息(類似黑白效果中的處理),否則浮雕圖像會出現錯誤的彩色顏色。

在使用HLSL處理浮雕效果的時候,有兩個問題我們需要注意一下。

因爲在Pixel Shader中紋理的採樣座標[u,v]是歸一化的[0-1.0]^2(向量空間表示法),在計算臨近像素座標時,首先需要知道該採樣座標對應的像素座標,此時需要知道紋理的像素尺寸大小,假設紋理的像素尺寸大小爲[w,h],那麼採樣點的像素座標即爲[u*w,v*h];計算過程如下圖所示:

其次其左上角像素點的像素座標即爲[u*w-1,v*h-1],然後再將這一座標歸一化到[0-1.0]^2之間,即[(u*w-1)/w,(v*h-1)/h],化簡後得到[u-1/w,v-1/h];

此時若採樣點是左上角或左上兩邊時,即點[0.0,0.0]、[u,0.0] 或[0.0,v],其中u>0.0、v>0.0,那麼該採樣點左上角或左上邊的象素的紋理座標就是[0.0 - 1.0/w,0.0 - 1.0/h]、[u - 1.0/w,0.0 - 1.0/h] 或[0.0 - 1.0/w,v - 1.0/h],其中至少有一個採樣點歸一化座標爲負數。

這樣我們就面臨兩個問題:

其一就是Shader中無法知道這個紋理的像素大小。這需要我們在C++代碼側,通過根簽名常量參數的形式當作一個const常量設置給HLSL就可以了。或者固定一個紋理的大小比如1024 x 1024,這樣也可以。

其二是圖像邊界處採樣點座標至少有一個是負數,與在[0-1.0]^2區間內的要求相左。這個時候就需要做特殊處理,通常把邊界位置的浮雕結果設置成背景顏色。這通常不需要在Shader中去對圖像的邊界做特殊處理,而只需要將採樣器設置爲某種連續模式就可以了。在本章例子中爲了照顧大多數特效我們設置爲WRAP(包裹)模式。

爲了傳遞特效需要的額外參數,我們特意在代碼中又定義了一個常量緩衝如下,代碼列在下面。

Shader中:

cbuffer PerObjBuffer : register(b1)
{
    uint g_nFun;
    float2 g_v2TexSize;
    float g_fQuatLevel = 2.0f;    //量化bit數,取值2-6
    float g_fWaterPower = 40.0f;  //表示水彩擴展力度,單位爲像素
};

C++代碼中:

struct ST_GRS_PEROBJECT_CB
{
    UINT    m_nFun;
    XMFLOAT2 m_v2TexSize;
    float   m_fQuatLevel;    //量化bit數,取值2-6
    float   m_fWaterPower;  //表示水彩擴展力度,單位爲像素
};

其中g_v2TexSize就是紋理的像素尺寸大小。

根據紋理像素尺寸計算採樣點像素座標,然後再計算臨近像素座標的方法,是一個非常重要的技巧,幾乎很多高級的紋理操作中都需要傳入紋理尺寸,然後通過將在Shader中歸一化的紋理座標乘以對應紋理像素大小後,可以得到像素座標,方便計算臨近像素的座標。通常在Shader代碼中使用[1.0/w,1.0/h]作爲一個像素的歸一化座標偏移量,前面已經有詳細推導過程了,建議最好是記住。有了像素便宜量,就可以取得某一像素採樣點臨近像素(“九宮格”)的顏色值,進行一些混色操作,我們後續的很多特效都使用了這一技巧。

有了上面的基礎,我們就可以實現浮雕效果Shader函數如下:

float4 Anaglyph(PSInput input)
{
    //實現浮雕效果
    float2 upLeftUV = float2(input.m_v2UV.x - 1.0 / g_v2TexSize.x
        , input.m_v2UV.y - 1.0 / g_v2TexSize.y);
    float4 bkColor = float4(0.5, 0.5, 0.5, 1.0);
    float4 curColor = g_texture.Sample(g_sampler, input.m_v2UV);
    float4 upLeftColor = g_texture.Sample(g_sampler, 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;
}

在上面的代碼中我們首先計算得到了將要採樣的紋理像素點的左上角像素採樣點的歸一化座標,然後,兩個Sample採樣方法取得了兩點的顏色值,接着計算出採樣點與其左上角像素的顏色差,在以此顏色根據我們之前介紹的計算顏色亮度的方法,計算出亮度值,接着形成一個灰度顏色值,再跟背景灰度顏色相加形成最終採樣點的顏色。

結合之前的知識,這個函數很好理解。還需要大家注意的一點就是[0,255]範圍的顏色值在映射到[0.0-1.0]的歸一化顏色空間中時,128顏色值對應的就是0.5這一歸一化顏色值,對應關係就是:顏色值/255即可。這個也很好理解。其實由此也可以看出在我們的Shader編程中經常要用到這種歸一化的座標系(廣義座標系,理解爲歸一化的線性空間最好,或者稱之爲標準化向量)。當然這主要是因爲我們通常將渲染目標的像素格式設置爲UNORM形式,其含義就是unsigned normalize(無符號標準化/歸一化)。

最終浮雕效果運行效果如下:

 

圖中我們看到地球的顯示效果類似於海洋乾涸後的樣子。

6、馬賽克效果

馬賽克(Mosaic)效果就是把圖片的一個指定大小的區域用同一個點的顏色來表示。目的是大規模的降低圖像的分辨率,而讓圖像的一些細節隱藏起來。(因爲有些細節從此無法分辨,也被稱爲“萬惡的馬賽克”)。

用Shader代碼實現馬賽克是非常簡單的。

第一步就是先把紋理座標轉換成像素座標(前一節我們已經學習過該方法)。接下來要把圖像這個座標量化---比如馬賽克塊的大小是8x8像素。那麼我們可以用下列方法來得到馬賽克後的圖像採樣值,假設[x,y]爲圖像的像素座標:

mosaic[X,Y] = [int(x/8) * 8 , int(y/8) * 8]

式中8可以是參數量的大小,在我們的Shader代碼中就是應用一個全局的static型變量存儲了這個參數的大小。前面的章節中我們也介紹了這個變量。

公式中int函數實際上截斷了小數部分,因此馬賽克大小範圍內的像素點取整後的值都是左上角像素點的座標。

得到這個座標後只要用相反的方法,把座標歸一化到[0,1.0]的紋理座標。

由此我們可以實現方形馬賽克效果函數如下:

static float2 g_v2MosaicSize1 = float2(32.0f, 32.0f);
float4 Mosaic1(PSInput input)
{//方形馬賽克
    float2 v2PixelSite
        = float2(input.m_v2UV.x * g_v2TexSize.x
            , input.m_v2UV.y * g_v2TexSize.y);

    float2 v2NewUV
        = float2(int(v2PixelSite.x / g_v2MosaicSize1.x) * g_v2MosaicSize1.x
            , int(v2PixelSite.y / g_v2MosaicSize1.y) * g_v2MosaicSize1.y);

    v2NewUV /= g_v2TexSize;
    return g_texture.Sample(g_sampler, v2NewUV);
}

代碼邏輯很簡單,也很好理解,就不再贅述了。

方形馬賽克運行效果圖如下(注意我將馬賽克大小設置爲32*32):

 

方形的馬賽克實現很簡單,進一步我們可以實現稍微複雜一點的圓形馬賽克。

首先求出原來馬賽克區域的正中心(原來是左上角):然後計算圖像採樣點到這個中心的距離,如果在馬賽克圓內,就用區域的中心顏色,否則就用原來的顏色。改良後的代碼如下,這裏把馬賽克區域大小調節成16x16。這樣效果更明顯。

static float2 g_v2MosaicSize2 = float2(16.0f, 16.0f);
float4 Mosaic2(PSInput input)
{//圓形馬賽克
    float2 v2PixelSite
        = float2(input.m_v2UV.x * g_v2TexSize.x
            , input.m_v2UV.y * g_v2TexSize.y);

    //新的紋理座標取到中心點
    float2 v2NewUV
        = float2(int( v2PixelSite.x / g_v2MosaicSize2.x) * g_v2MosaicSize2.x
            , int(v2PixelSite.y / g_v2MosaicSize2.y) * g_v2MosaicSize2.y)
        + 0.5 * g_v2MosaicSize2;

    float2 v2DeltaUV = v2NewUV - v2PixelSite;
    float fDeltaLen = length(v2DeltaUV);

    float2 v2MosaicUV = float2( v2NewUV.x / g_v2TexSize.x,v2NewUV.y / g_v2TexSize.y );
    float4 c4Color;
    //判斷新的UV點是否在圓心內
    if ( fDeltaLen < 0.5 * g_v2MosaicSize2.x )
    {
        c4Color = g_texture.Sample(g_sampler, v2MosaicUV);
    }
    else
    {
        c4Color = g_texture.Sample(g_sampler, input.m_v2UV);
    }
    return c4Color;
}

上面的代碼邏輯已經很清晰了,只是需要注意的是else分支中的Sample採樣調用可能會被浪費了,因爲它在圓外,有可能在臨近的圓內,這時又會重採樣爲它所在的圓的圓心處的顏色。當然不在任何圓內的點就顯示爲原來的紋理顏色。

大家可以仿照這一思路繼續發揮,可以編寫比如“三角形”,“六邊形”等的馬賽克濾鏡。

最終圓形馬賽克運行效果如下:

 

最後需要提醒大家的是,因爲紋理的座標都被歸一化到了[0,1.0]區間,而每個紋理又是不同的像素大小,所以對於最終馬賽克的顯示大小是不同的,這一點一定要注意。如果要顯示的馬賽克大小一致,那麼就必須保證所有紋理的原始像素大小必須保持一致,大家可以使用圖像編輯工具改變這幾個紋理的原始大小爲同一個尺寸運行後看看效果。

7、圖像數字濾波

接下來我們要介紹稍微複雜一點的效果,第一個就是圖像的模糊和銳化。

圖像的模糊又成爲圖像的平滑(smoothing),人眼對高頻成分是非常敏感的。如果在一個亮度連續變化的圖像中,突然出現一個亮點,那麼我們很容易察覺出來。類似的,如果圖像有個突然的跳躍—明顯的邊緣,我們也是很容易察覺出來的。這些突然變化的分量就是圖像的高頻成分。人眼通常是通過低頻成分來辨別輪廓,通過高頻成分來感知細節的(這也是爲什麼照片分辨率低的時候,人們只能辨認出照片的大概輪廓,而看不到細節)。但是這些高頻成分通常也包含了噪聲成分。圖像的平滑處理就是濾除圖像的高頻成分。

那麼如何才能濾除圖像的高頻成分呢?先來介紹一下圖像數字濾波器的概念。

簡單通俗的來說,圖像的數字濾波器其實就是一個n*n的數組(數組中的元素成爲濾波器的係數或者濾波器的權重,n稱爲濾波器的階)。對圖像做濾波的時候,把某個像素爲中心的n*n個像素(“九宮格”)的值和這個濾波器做卷積運算(也就是對應位置上的像素和對應位置上的權重的乘積累加起來),公式如下:

其中x , y 爲當前正在處理的像素座標。

通常情況下,濾波器的階數爲3已經足夠了,用於普通模糊處理的3*3濾波器如下:

經過這樣的濾波器,其實就是等效於把一個像素和周圍8個像素一起求平均值,這是非常合理的---等於把一個像素和周圍幾個像素攪拌在一起—自然就模糊了。

基於這個原理,我們先定義一個3*3濾波Shader函數如下:

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

在上面代碼中,我們首先定義了一個以像素爲單位的偏移矩陣,即指定了上下左右八個方向的像素座標偏移向量矩陣。如果需要更高階的濾波,那麼可以擴展這個矩陣,並且將循環中的次數修改爲更高階即可。基於這種思路,大家有興趣可以試着實現一個4階或更高階的濾波函數來看下效果。

8、模糊濾波器(Box模糊和高斯模糊)

有了核心的濾波函數,我們首先來實現一個普通的模糊濾波器,也被稱爲BOX濾波器,是最簡單的模糊濾波器,即中心像素取周邊像素的平均值,其代碼如下:

float4 BoxFlur(float2 v2UV,float2 v2TexSize)
{//簡單的9點Box模糊
    float2 v2PixelSite
        = float2(v2UV.x * v2TexSize.x, v2UV.y * v2TexSize.y);
    float3x3 mxOperators
        = float3x3(1.0f / 9.0f, 1.0f / 9.0f, 1.0f / 9.0f
                    , 1.0f / 9.0f, 1.0f / 9.0f, 1.0f / 9.0f
                    , 1.0f / 9.0f, 1.0f / 9.0f, 1.0f / 9.0f);
    return Do_Filter(mxOperators, v2PixelSite, v2TexSize, g_texture);
}

其運行效果如下:

如果考慮到離開中心像素的距離對濾波器係數的影響,我們通常採用更加合理的濾波器---高斯濾波器——一種通過2維高斯採樣得到的濾波器,它的濾波矩陣如下:

很容易看出來,離開中心越遠的像素,權重係數越小(對角線總是長於邊長)。其代碼實現如下:

float4 GaussianFlur(float2 v2UV, float2 v2TexSize)
{//高斯模糊
    float2 v2PixelSite
        = float2(v2UV.x * v2TexSize.x, v2UV.y * v2TexSize.y);

    float3x3 mxOperators
        = float3x3(1.0f / 16.0f, 2.0f / 16.0f, 1.0f / 16.0f
            , 2.0f / 16.0f, 4.0f / 16.0f, 2.0f / 16.0f
            , 1.0f / 16.0f, 2.0f / 16.0f, 1.0f / 16.0f);

    return Do_Filter(mxOperators, v2PixelSite, v2TexSize, g_texture);
}

運行後效果如下:

從兩個模糊效果來看,幾乎看不出有什麼差別,尤其與原圖比較也沒有較大的變化,這主要是這麼幾個原因:1、一般用於3D場景的紋理都經過了“去噪”處理,實際就是去高頻濾波,有點類似於我們這裏進行的模糊處理;2、我們的濾波還只是簡單的3階,要更加模糊的效果就需要更高階的模糊處理;3、因爲人類的視覺習慣,我們實際對低頻的畫面變化不敏感,這樣本就沒有多少高頻成分的畫面經過濾波後基本看不出太大變化;所以大家可以通過修改紋理(加入高頻噪聲)或者實現更高階的濾波函數來改進這個模糊效果。

9、銳化效果(拉普拉斯銳化)

對於銳化操作,常用的銳化濾波算子是拉普拉斯(Laplacian)銳化算子,這個濾波算子矩陣定義如下:

容易看出拉普拉斯矩陣算子的操作如下:先將自身與周圍的8個象素相減,表示自身與周圍象素的差別;再將這個差別加上自身作爲新象素的灰度。如果一片暗區出現了一個亮點,那麼銳化處理的結果是這個亮點變得更亮,這就增強了圖像的細節。

據此實現Shader函數如下:

float4 LaplacianSharpen(float2 v2UV, float2 v2TexSize)
{//拉普拉斯銳化
    float2 v2PixelSite
        = float2(v2UV.x * v2TexSize.x, v2UV.y * v2TexSize.y);

    float3x3 mxOperators
        = float3x3(-1.0f, -1.0f, -1.0f
            , -1.0f, 9.0f, -1.0f
            , -1.0f, -1.0f, -1.0f);
    return Do_Filter(mxOperators, v2PixelSite, v2TexSize, g_texture);
}

其運行效果如下:

從圖中可以明顯看出多了很多“亮點”噪聲,色彩對比度也有很大提升,輪廓部分也很清晰。大家可以修改拉普拉斯算子矩陣中間位置的值,適當調低,可以得到一個較滿意的“銳化”濾鏡效果。

通過這些效果,我們介紹了圖像的濾波操作,這樣的操作,也稱爲模板操作。它實現了一種鄰域運算(Neighborhood Operation),即某個像素點的結果灰度不僅和該像素灰度有關,而且和其鄰域點的值有關。模板運算在圖像處理中經常要用到,可以看出這是一項非常耗時的運算。有一種優化的方法稱爲可分離式濾波,就是使用兩個pass來進行x/y方向分別濾波,能讓運算次數大大減少。而且濾波器階數越高,優勢越明顯。

數字圖像濾波的時候,同樣還需要注意邊界像素的問題,不過幸好,HLSL能讓邊界處理更加的透明和簡單(之前講的在Sample上設置邊界連續的採樣器屬性)。

10、描邊

利用濾波函數,我們可以實現一種比浮雕效果更好效果的稱之爲描邊的效果,描邊(邊緣檢測)的代碼並不複雜多少,只是在理論上相對來說稍微複雜一點,而且效果看上去更加的“順眼”一些。

如果在圖像的邊緣處,灰度值肯定經過一個跳躍,我們可以計算出這個跳躍,並對這個值進行一些處理,從而得到邊緣濃黑的描邊效果。

首先考慮對這個象素的左右兩個象素進行差值,得到一個差量,這個差量越大,表示圖像越處於邊緣,而且這個邊緣應該左右方向的。同樣我們能得到上下方向和兩個對角線上的圖像邊緣。這樣我們需要構造一個如下的濾波器矩陣算子:

經過這個濾波器後得到的是圖像在這個像素處的變化差值,把它轉化成灰度值並求絕對值(差值可能爲負),然後我們定義差值的絕對值越大的地方越黑(邊緣顯然是黑的),否則越白。其代碼實現如下:

float4 Contour(float2 v2UV, float2 v2TexSize)
{//描邊
    float2 v2PixelSite
        = float2(v2UV.x * v2TexSize.x, v2UV.y * v2TexSize.y);
    float3x3 mxOperators
        = float3x3(-0.5f, -1.0f, 0.0f
            , -1.0f, 0.0f, 1.0f
            , -0.0f, 1.0f, 0.5f);

    float4 c4Color = Do_Filter(mxOperators, v2PixelSite, v2TexSize, g_texture);

    float fDeltaGray = 0.3f * c4Color.x + 0.59 * c4Color.y + 0.11 * c4Color.z;

    //fDeltaGray += 0.5f;

    //OR

    if (fDeltaGray < 0.0f)
    {
        fDeltaGray = -1.0f * fDeltaGray;
    }
    fDeltaGray = 1.0f - fDeltaGray;

    return float4(fDeltaGray, fDeltaGray, fDeltaGray, 1.0f);
}

代碼中邏輯很清晰了,只是最後計算得到亮度值後的處理,使用了與浮雕效果不同的方法,大家可以恢復被註釋的代碼,並註釋後面這個if判斷及1.0- fDeltaGray的語句看看效果,並與浮雕效果進行比較看看差異。

上面的效果運行效果如下:

從圖中可以看到一些高頻噪點,大家可以分析下爲什麼會出現這樣的情況,有什麼方法可以補救?

11、索貝爾濾波算子(Sobel,或譯作索伯爾)

上面演示的效果中用到的濾波矩陣算子就是一種邊緣檢測器,在信號處理上是一種基於梯度的濾波器,又稱邊緣算子。梯度(實際就是向量的導數)是有方向的,和邊沿的方向總是正交(垂直)的,在上面的代碼中,我們採用的就是一個梯度爲45度方向模板(注意其中偏移像素的權重取值和正負號),它可以檢測出135度方向的邊沿。

更加嚴格的,我們可以採用Sobel算子。Sobel 算子有兩個,一個是檢測水平邊沿的,另一個是檢測垂直平邊沿的,如下:

同樣,Sobel算子另一種形式是各向同性Sobel算子,也有兩個,一個是檢測水平邊沿的,另一個是檢測垂直邊沿的:

各向同性Sobel算子和普通Sobel算子相比,它的位置加權係數更爲準確,在檢測不同方向的邊沿時梯度的幅度一致。

爲了綜合展示垂直和水平方向上的Sobel算子效果,在示例Shader代碼中,特意將兩個方向計算的結果進行了相加處理,代碼實現如下:

float4 SobelAnisotropyContour(float2 v2UV, float2 v2TexSize)
{//各向異性索博爾描邊
    float2 v2PixelSite
        = float2(v2UV.x * v2TexSize.x, v2UV.y * v2TexSize.y);
    float3x3 mxOperators1
        = float3x3(  -1.0f, -2.0f, -1.0f
                    , 0.0f, 0.0f, 0.0f
                    , 1.0f, 2.0f, 1.0f
                  );

    float3x3 mxOperators2
        = float3x3(  -1.0f, 0.0f, 1.0f
                    ,-2.0f, 0.0f, 2.0f
                    ,-1.0f, 0.0f, 1.0f
                  );

    float4 c4Color1 = Do_Filter(mxOperators1, v2PixelSite, v2TexSize, g_texture);
    float4 c4Color2 = Do_Filter(mxOperators2, v2PixelSite, v2TexSize, g_texture);

    c4Color1 += c4Color2;

    float fDeltaGray = 0.3f * c4Color1.x + 0.59 * c4Color1.y + 0.11 * c4Color1.z;

    if (fDeltaGray < 0.0f)
    {
        fDeltaGray = -1.0f * fDeltaGray;
    }
    fDeltaGray = 1.0f - fDeltaGray;

    return float4(fDeltaGray, fDeltaGray, fDeltaGray, 1.0f);

}

float4 SobelIsotropyContour(float2 v2UV, float2 v2TexSize)
{//各向同性索博爾描邊
    float2 v2PixelSite
        = float2(v2UV.x * v2TexSize.x, v2UV.y * v2TexSize.y);
    float3x3 mxOperators1
        = float3x3(  -1.0f, -1.414214f, -1.0f
                    , 0.0f, 0.0f, 0.0f
                    , 1.0f, 1.414214f, 1.0f
                    );

    float3x3 mxOperators2
        = float3x3(   -1.0f, 0.0f, 1.0f
                    , -1.414214f, 0.0f, 1.414214f
                    , -1.0f, 0.0f, 1.0f
                    );

    float4 c4Color1 = Do_Filter(mxOperators1, v2PixelSite, v2TexSize, g_texture);
    float4 c4Color2 = Do_Filter(mxOperators2, v2PixelSite, v2TexSize, g_texture);

    c4Color1 += c4Color2;

    float fDeltaGray = 0.3f * c4Color1.x + 0.59 * c4Color1.y + 0.11 * c4Color1.z;

    if (fDeltaGray < 0.0f)
    {
        fDeltaGray = -1.0f * fDeltaGray;
    }
    fDeltaGray = 1.0f - fDeltaGray;

    return float4(fDeltaGray, fDeltaGray, fDeltaGray, 1.0f);
}

代碼中對於根號2取了其有限近似值,代碼中的邏輯也很清晰,爲了查看不同方向上的實際效果,建議大家註釋某一個方向的計算代碼,只保留另一個方向的計算看看實際效果。當然也可以採用浮雕中的加上固定背景色的方法來看看是什麼效果。

這兩個效果運行效果如下:

上圖是各向異性的效果。下圖是各向同性的效果:

實際上可以發現兩個效果區別不是很明顯,這主要是因爲二者實際矩陣算子中的因子差異不是很大,一個是2,另一個是1.414,幾乎已經看不出什麼差別了。

最後提示大家注意的就是,我們的幾個效果函數最後處理灰度值的時候,使用了負值直接變正值,然後用1減去亮度值的方法,其實嚴格來說這個方法是不正確的,因爲這裏沒有考慮溢色問題,即顏色值超出了[0,1]^3範圍外的問題,這時正確的做法應該是取所有顏色分量的最小最大範圍區間[min,max],然後用(溢色顏色值-min)/(max-min)進行正確歸一化,即進行全景的HDR映射計算(實際壓縮了圖像細節的亮度、對比度等)。但這對一般的實時圖像處理來說代價太大,因爲要遍歷一副渲染圖中所有的顏色值,所以一般採用較簡單的處理方法,即取計算所得的顏色值中的分量來做映射歸一化計算,代碼示例如下:

//計算顏色溢色的簡單方法
float fMaxColorComponent
    = max(c4Color1.x
    , max(c4Color1.y
        , c4Color1.z));
float fMinColorComponent
    = min(
    min(c4Color1.x
        , min(c4Color1.y
            , c4Color1.z))
    ,0.0f);

c4Color1 = (c4Color1 - float4(fMinColorComponent
    , fMinColorComponent
    , fMinColorComponent
    , fMinColorComponent))
    /(fMaxColorComponent- fMinColorComponent);

在各向異性的索伯爾算子的代碼中,我加入了這段代碼,並註釋了。大家可以自行還原,然後註釋後面的if判斷及1-亮度值語句後看看效果。

當然對於實時渲染來說,這樣的溢色處理基本足夠了。這個方法很重要建議大家能夠理解並記憶。最好能夠將這個溢色處理過程封裝到Shader函數庫,以供將來使用。(在後續的Raytracing Shader教程中任然會用到這個方法)。

12、後記

細心的網友肯定發現這一章教程是不完整的,因爲至少示例代碼中的水彩畫的效果還沒有講解,其實直到我寫這篇文章時,我才發現水彩畫效果其實是有問題的,因爲少了一遍高斯模糊處理,所以必須要回爐再造,並且加上這個“後處理”纔算正確,所以我打算放在後面一章再講解。另外還有一個僞HDR效果也沒有講解,因爲當時我以爲現在僞HDR沒啥用了,後來我發現是我自個估計錯了,同時這個效果可能是解決前面講的“溢色”問題的另一個思路,所以考慮下一章將它也補充進來。

敬請期待!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章