關鍵字 Direct3D,Shader,HLSL
寫過Direct3D程式的朋友們可能還記得,在以往,大家常為如何表現更多真實的材質(如玻璃、金屬等)而發愁。這種情況在DirectX8.0問世後有所改善了,我們可以編寫Shader來完成。最新的Direct3D中,HLSL把程式員從複雜的Shader指令集中解放出來,著力於更重要的演算法。
HLSL(High-Level Shader Language)本文將從如下幾個部分介紹
準備工作
HLSL基礎
執行個體分析
首先,什麼是Shader?什麼是HLSL?
簡要地說,Shader就是一種指令碼程式,相對獨立於D3D主程式,並且被編譯成顯卡的GPU指令序列在顯示晶片上跑。(你肯定想知道更多,比如這種程式用什麼來寫,都要寫什麼,怎麼讓GPU跑這種程式等等,別著急慢慢來),這裡有必要先瞭解一下:
AGP顯卡的渲染流程:
首先來根據下面這張圖粗略說明一下當前最普遍流行的AGP顯卡的渲染流程,甭管是nVidia還是ATI哪一邊的。
每次渲染過程(例如,在一幀畫面中畫一個饅頭的過程)都包括頂點處理(Vertex Processing)和像素處理(Pixel Processing)兩個主要功能模組的執行。首先,顯卡從AGP匯流排接收這個饅頭的頂點資料。這些資料包括位置、法線、貼圖座標(如果是麵包可能更需要貼圖,也就是說貼圖座標不是必需的)等等,這些都是未經過任何變換,也就是在物體本地空間(Object Space)下的原始座標。每個頂點依次被送入頂點處理單元,在這裡進行座標變換、光照計算(如果是每頂點光照)等工作,變換的結果是把每個三角形變換置螢幕空間(Screen Space)下直接可用。這裡用到的變換矩陣、燈光等資訊都是處理每一批頂點時一次性傳給顯卡的,作為顯卡的資源。頂點處理圈定了三角形的範圍,接下來就要逐像素地填充這個三角形了。填充哪些像素是靠對頂點螢幕座標的線性插值來決定的。像素的其他一些必要參數,如顏色,貼圖座標等也是通過對上一步計算出來的頂點的這些屬性進行插值得到的。另外每個像素還要通過深度檢測和模板檢測決定最終是否繪製。需要繪製的像素被送進像素處理模組,進行貼映像素取值,貼圖混合等工作,必要的話每像素光照也在這裡完成。這裡貼圖等資訊也是作為顯卡的資源。像素最終的處理結果被放進後備緩衝。
以往顯卡在頂點處理和像素處理過程中執行的是一套布在硬體上的固定的程式,D3D程式員只能設定一些參數,實際上就是你調用IDirect3DDevice::SetRenderState()時做的事,而這樣的程式在IDirect3DDevice::DrawPrimitive()中自動執行。那麼有些事情就很難辦到,如渲染一個水晶饅頭。應為參數再多,其渲染所用到的光照公式也跑不出石膏這種東西。現在的顯卡,確切的說是現在的Direct3D允許你寫這麼一段程式替代固定的頂點處理過程和像素處理過程(記住,只是這兩個過程,跟插值什麼的沒關係)。其中替換頂點處理的就叫Vertex Shader(暫時還沒有確切的中文名),替換像素處理的就叫Pixel Shader。就是開篇所說的Shader。
這樣你應該想到Shader中大概應該寫些什麼了。如果還不行的話建議複習一下D3D。用什麼來寫呢?三。GPU自有GPU的指令集,以往的Shader就是用這種彙編式的指令集組成,例如:
vs_1_1
dcl_position v0
dcl_normal v1,r0, v0.x, c0
mad r2, v0.y, c1, r0
就如同彙編用多了必然出現C一樣,自Direct3D9.0後,一種叫HLSL(High Level Shading Language,進階渲染語言)的面向過程的Shader語言應運而生,本篇將詳細介紹的即為此。
HLSL基礎
就像每一本程式設計語言的教材一樣,介紹一門語言,首先從它的資料類型,運算式,控制流程這些東西說起。HLSL的這些基本文法很像C/C++,不再贅述。有些常見問題還是要說明一下,是為了讓你不會被這些牽制了全面瞭解Shader的腳步。
資料類型
與CPU不同,在顯卡晶片中,最小的資料吞吐單元是一個由32位浮點數組成的四元組。這一點很有道理不是,想想你在渲染過程中所有涉及到的資料,最複雜的不外乎四維座標(x,y,z,w)或顏色(r,g,b,a),這樣GPU可以一次性處理一個四元組。而整數什麼的在顯卡中被放到四元組的一個分量裡使用,而很多顯卡中,整數、布爾值都不被直接支援,而是轉為浮點數使用。至於矩陣,通常用4個四元組表示一個4x4矩陣(預設情況一個四元組儲存一行,也可以指定按列儲存,屬於細節問題,goto:細節問題)其他尺寸的以此類推。
反映到程式上,一個四維向量就被聲明為float4,4維方矩陣被聲明為float4x4等等。當然,你也可以使用任意不超過4的維度向量或矩陣,如int3,float3x3,double1。這個double1實際上就是標量了,1可以省略不寫。想知道更多,就goto:細節問題
紋理(Texture)&取樣器(Sampler)
這倆東西可以看作特殊類型變數。紋理就是Shader中用到的貼圖資源,這我想沒什麼好說的。來解釋一下取樣器:實際上每張貼圖在使用的時候都要用一個取樣器。取樣器相當於這樣一個結構,除了儲存貼圖本身資料之外,還包括過濾參數等取樣資訊。通常,讀取貼圖這樣的指令接收的都是取樣器類型的參數而並非直接接收紋理貼圖。聲明及使用紋理或取樣器跟使用普通變數一樣。這裡有一些初始化取樣器的方法,還是等到後面的執行個體中講述吧。更多內容請goto:細節問題
Semantic & Annotation
任何類型的變數(包括紋理和取樣器),我們都可以用Semantic或Annotation修飾來起到一些特殊作用。Semantic暫時翻譯成語義;Annotation暫時翻譯成註解,這是HLSL中獨特的東西。下面這兩行中,第一個變數冒號後面的POSITION就是Semantic,第二個變數後面用一對角括弧<>圈起來的運算式就是Annotation,一組<>中可以有很多個運算式。
float3 OmniPos : POSITION;
texture TexMap < string name = "test.dds"; >;
一般來講,Semantic是告訴應用程式或D3D這個被修飾的變數是做什麼用的,Annotation是告訴程式這個變數怎麼用。很雲山霧罩是嗎,是這樣,在應用程式代碼中,是可以調用D3D的API認出Semantic和Annotation的。例如上面這兩行,程式的邏輯就可能是這樣:首先寫主程式的甲和寫Shader的乙約定好POSITION標識該變數代表燈泡A的位置,甲在程式裡寫:{燈泡A.位置 = XXX; 找到Shader中帶POSITION的變數; 給該變數賦值為燈泡A.位置; return;} 那麼甲可以不知道乙在Shader中給這個要用燈泡A位置的變數起了什麼名,而且乙可以在好幾個Shader中給用這個資料的變數起不同的名。然後,甲和乙再約定遇到Annotation中的“name”就將後面的字串作為檔案名稱建立貼圖。於是甲的程式就從Shader中讀出了一個檔案名稱,建立了一個貼圖以供這個texture變數使用。Semantic和Annotation大概就這麼用,首先要約定好各個Semantic和Annotation都是什麼意思,這是up to you的,然後就是通過它們的標識來給變數賦值或作其他輔助性工作了。
既然都是做輔助說明的為什麼還要分成Semantic和Annotation,我的想法是Semantic簡單方便而Annotation能乾的事更多。不說這個了,無關大局。要說的是,D3D也跟我們約定了一套Semantic,它們大體上都能顧名思義,詳細資料在後面。
控制流程
控制流程,就是if…else,for,while什麼的。在CPU中,這些控制流程造成的實際上是指令跳轉。但在GPU中指令跳轉並不被廣泛的支援,以往的大部分顯卡只懂得按順序一句一句執行指令。因此HLSL的編譯器可能會做出諸如展開迴圈、遍曆分支等等莽撞的事來適應顯卡。所以使用時要特別小心,而且不是所有情況的控制流程語句都被支援。具體的很多規則還是在細節問題裡。
函數
HLSL中提供了很多函數可供調用,在Direct3D 文檔 -> DirectX Graphics -> Reference -> HLSL Shader Reference -> HLSL Intrinsic Functions中有這些函數的詳細列表。也可自己寫函數用,但是在較早的Shader版本中,就像內嵌函式一樣編譯時間最終要將函數展開插入到函數調用處。還有一點我想你一定會想到的就是主函數會是什麼。Vertex Shader和Pixel Shader各自需要一個主函數,由程式員來指定!沒錯,程式員在Shader外部指定。具體方法將在下篇講述(注意不是細節問題)。
// Declare a global uniform variable
float4 UniformGlobal;
float4 main( uniform float4 UniformParam ) : POSITION
{
return UniformGlobal * UniformParam;
}
細節問題
你會覺得前面說的太過粗略,還有很多問題沒有敘述,但相對來講這些都算是細枝末節了。例如HLSL中保留關鍵字有哪些;變數的範圍;資料類型的詳細資料;四元組分量的使用法則等等,這些在Direct3D文檔 -> DirectX Graphics -> Programming Guide -> The Programmable Pipeline -> Programmable HLSL Shaders -> HLSL Language Basics中講得比我清楚,我也不再多餘翻譯了。
執行個體分析
抑或你覺得看到前面的介紹後有些激動,躍躍欲試想接觸實際代碼,瞭解Shader的全貌了。那好我們就分析一個例子開始。
我們通過兩個典型的Shader來看看怎麼用它來實現我們想要的效果。最好先回顧一下前面的渲染流程,再次熟悉一下從Object空間下的頂點資料流到顯示器像素的途徑。然後不要著急理解下面代碼的每一句,僅僅瀏覽一遍,稍後我會帶你分析的。
先是一個Vertex Shader:
/////////////////////////////////////////////////////////////////////////////
// Copyright (c) FrontFree_Studio. All rights reserved.
/////////////////////////////////////////////////////////////////////////////
/**************** GLOABLE VARIABLES ****************/
float4 MtrlSpec = { 1.0f, 1.0f, 1.0f, 1.0f }; // material’s specular color, white
float SpecPow = 8;
float3 OmniPos : POSITION = { 0.577, 0.577, -0.577 }; // lights (world space)
float4 OmniColor = { 1.0f, 1.0 f, 1.0f, 1.0f }; // light’s color, white
float4 AmbLight = { 0.9f, 0.9f, 0.9f, 0.9f }; // ambient light color;
float3 CameraPos : CAMERAPOSITION = { 0.0f, 3.0f, 5.0f };
// camera (world space)
float4x3 matWorld : WORLD;
float4x4 matViewProj : VIEWPROJ;
/***** vertex shader output structure *********/
struct VS_OUTPUT
{
float4 Pos : POSITION;
float4 Intensity : COLOR0;
float4 Spec : COLOR1;
float3 Texcoord : TEXCOORD;
};
/**************** VERTEX SHADER ****************/
VS_OUTPUT VS( float3 InPos : POSITION, float3 InNormal : NORMAL, float2 InTexcoord : TEXCOORD )
{
VS_OUTPUT Out = (VS_OUTPUT)0;
//Calculate the output color by per-pixel lighting
// first, calculate the diffuse component
float3 P_World = mul(float4(InPos, 1), matWorld); // position to world space
float3 ToLight = normalize( P_World – OmniPos );
float3 Normal = normalize( InNormal );
float4 Diff = dot( ToLight, Normal ) * OmniColor;
Out.Intensity = Diff + AmbLight;
// then, the specular component
float3 Reflection = reflection( -ToLight, Normal );
float3 ToEye = normalize( CameraPos – P_World );
float4 Spec = pow( dot( Reflection, ToEye ), SpecPow ) * MtrlSpec * OmniColor;
Out.Spec = Spec;
// Output the position, texture coordination
Out.Pos = mul(float4(P_World, 1), matViewProj);
Out.Texcoord = InTexcoord;
return Out;
}
下面是個Pixel Shader:
/////////////////////////////////////////////////////////////////////////////
// Copyright (c) FrontFree_Studio. All rights reserved.
/////////////////////////////////////////////////////////////////////////////
/**************** TEXTURES ****************/
texture TexMap < string name = "test.dds"; >;
sampler2D TexSampler = sampler_state
{
Texture = <EnviMap>;
MipFilter = LINEAR;
MinFilter = LINEAR;
MagFilter = LINEAR;
};
/**************** PIXEL SHADER ****************/
float4 PS ( VS_OUTPUT In ) : COLOR
{
float4 Tex = texture2D( TexSampler, In.Texcoord );
return Tex * In.Intensity + In.Spec;
}
這是一個簡單且無聊的例子,說它無聊是因為沒有任何特效,僅僅是有高光,外加一重紋理貼圖。如果不用Shader實現,僅需在D3D預設效果上多加一行代碼。這個例子只是為了告訴你Shader裡都需要幹什麼。首先是在Vertex Shader中將物體座標轉換至螢幕座標;同時進行光照計算出頂點的亮度,這裡用的是Phone模型。然後在Pixel Shader中進行紋理取樣,再混合上亮度得到最終結果。高光是在Vertex Shader中計算出來並在Pixel Shader中加上去的。
光照模型 說通俗一點,光照模型就是指採用何種方式來根據光源方向、頂點(或像素)位置、法線等資訊計算該頂點(或像素) 明暗資訊。上面的Shader中採用的是最常見的每頂點Phone模型。即每個頂點的明暗資訊由環境光線(Ambient)+漫反射光 (Diffuse)+高光(Specular)組成。通常,燈光打在物體上是通過漫反射進入眼睛,因此不管你從哪個方向看一個饅頭, 上面的大部分明暗只取決於背光還是向光。數學上就是取決於該頂點向光的方向(圖中的向量L)和其法線之間的夾角。線數中兩根標準化的 向量點乘結果就是其夾角的最好度量。因此程式裡也就這麼算。高光(準確講該叫鏡面反射光)就不一樣了。你應該見過這樣的情節 :張某疾步衝進綠柳莊,突然眼前金光一閃,“哦,倚天劍”……實際上倚天劍和陽光的位置並沒有改變,張某是否看見高光 還要取決於他視線方向和光線反射方向的關係。這兒有個比較複雜的數學公式來計算高光分量,相信讀者應該能把他找出來。至於 環境光線,實際的情境中光線是非常複雜的,即使沒有光源直接照射在物體上,它也將受到周圍物體漫射出來的光線照亮。Phone 模型中,假設環境光線是從四面八方來的強度一樣的光,因此環境光線照分量基本上是不取決於任何方向向量的常量。最後,各個分量 是加在一起最終決定明暗的。
上面只討論光照強度,記得美術老師講過,物體的顏色取決於光源本身的顏色,光照強度和物體材質的顏色。然後D3D老師講過,只要把那三個東西乘到一起就得到了最終的顏色。這裡的乘是指各個顏色分量(紅綠藍)分別相乘,實際上這是產生顏色過濾的方法。比如說,一個顏色值為(r,g,b,a)的顏色乘以50%灰(0.5,0.5,0.5,0.5)的結果就是(0.5r, 0.5g,0.5b,0.5a),亮度減了一半,相當於帶了個50%灰的眼鏡看。
由於本篇著重介紹的是HLSL語言,所以對於光照模型,筆者不打算做過多說明,將放到以後的文章中講述。
好了,來看代碼。每個Vertex Shader都要以頂點資料流中的資料作為其入口參數。但你頭腦裡清楚哪個參數是位置,哪個是法線,哪個是貼圖座標,你完全可以把法線叫m_Position,叫abcd,叫……D3D如何知道把資料流中的法線資訊往哪兒放呢?好,Semantic。D3D與我們約定了一套Semantic:其中POSITION是位置,NORMAL、TANGENT、BINOMAL分別是法線、切線、副法線,TEXTURE0-7表示8套貼圖座標。
切線是指頂點的切線向量,副法線是與法線和切線正交的向量,都是預計算好並存在頂點資料裡的。常用於凸凹貼圖技術。
Vertex Shader的輸出資料,也就是傳回值可以包含很多資訊,但是必須有一個float4是表示位置的。因為D3D認為,你可以在頂點處理過程中不作任何光照處理,但是必須要把頂點轉換至正確的螢幕位置。除了位置以外,其他都將向下傳入Pixel Shader作為其入口參數。這些參數也用Semantic標識。
搞清楚一點很重要,當每個頂點的顏色被計算出來之後,像素的顏色將由它所在的三角形的三個頂點插值得到。還記得Direct3D Tutorials2嗎?
你僅僅指定了這個三角形頂點的顏色,而中間那些好看的漸層效果實際上是硬體自己對頂點顏色進行插值得到每個像素的顏色。當我們使用光照的時候,只不過是用光照計算出的頂點顏色代替了你手動指定的顏色。貼圖座標也是這樣進行插值的。
Pixel Shader所接收的Semantic是COLOR0-7(顏色),TEXCOORD0-7(貼圖座標)。這裡用個結構體來放置所有的傳回值,以及Vertex Shader的函數原形一併如下所示:
struct VS_OUTPUT
{
float4 Pos : POSITION;
float4 Intensity : COLOR0;
float4 Spec : COLOR1;
float3 Texcoord : TEXCOORD;
};
VS_OUTPUT VS( float3 InPos : POSITION, float3 InNormal : NORMAL, float2 InTexcoord : TEXCOORD )
Shader中的處理過程無非也就是計算出四個傳回值分量。
首先位置(Pos)很好說,就是將頂點的原始位置(InPos)乘上變換矩陣。
float4x3 matWorld : WORLD;
float4x4 matViewProj : VIEWPROJ;
…
float3 P_World = mul(float4(InPos, 1), matWorld); // position to world space
Out.Pos = mul(float4(P_World, 1), matViewProj);
你看到這兩個全域變數被Semantic標誌為WORLD和VIEWPROJ,程式中將通過它們把當前的世界變換矩陣,視矩陣和投影矩陣的乘積傳進來。
這裡有個最佳化的原則。記住Vertex Shader是每處理一個頂點運行一遍,如果某些資訊對所有頂點(指同一批頂點流即同一物體中,使用同一個Shader的所有頂點)來說計算結果都一樣,那最好把它在程式中計算出來直接傳給Shader。例如三個變換矩陣的乘積。本例中,由於還需要把頂點位置變換置世界空間作他用,因此沒要求程式把矩陣都乘起來。
同理,每頂點能夠計算出來的東西就不要放在Pixel Shader中讓每像素都重複一遍。
將頂點乘以矩陣用到了HLSL的內建指令mul(),實現矩陣乘法。不牽扯到投影的三維座標變換並不需要第四維w座標,而且在這些變來變去的過程中w會始終保持為1。因此程式傳進來的頂點資料也是沒有w的。四維向量才被允許乘以4x4矩陣,因此我們還要把InPos和P_World補上w座標,即float4(InPos, 1)。
然後我們把每個頂點照亮賦給Color。這幾個全域變數就是光照要用到的一些材質顏色,燈光等資訊。我想應該能讓你看明白都是幹什麼的了。SpecPow是用於計算高光的,越大,反光面積越小。其餘的我想也不用多解釋了吧。
float4 MtrlSpec = { 1.0f, 1.0f, 1.0f, 1.0f }; // material’s specular color, white
float SpecPow = 8;
float3 OmniPos : POSITION = { 0.577, 0.577, -0.577 }; // lights (world space)
float4 OmniColor = { 1.0f, 1.0 f, 1.0f, 1.0f }; // light’s color, white
float4 AmbLight = { 0.9f, 0.9f, 0.9f, 0.9f }; // ambient light color;
// camera (world space)
float3 CameraPos : CAMERAPOSITION = { 0.0f, 3.0f, 5.0f };
…
float3 ToLight = normalize( P_World – OmniPos );
float3 Normal = normalize( InNormal );
float4 Diff = dot( ToLight, Normal ) * OmniColor;
Out.Intensity = Diff + AmbLight;
// then, the specular component
float3 Reflection = reflection( -ToLight, Normal );
float3 ToEye = normalize( CameraPos – P_World );
float4 Spec = pow( dot( Reflection, ToEye ), SpecPow ) * MtrlSpec * OmniColor;
Out.Spec = Spec;
至於貼圖座標,直接把它拷貝到傳回值就行了。
Out.Texcoord = InTexcoord;
進入Pixel Shader,所有入口參數由Vertex Shader中的計算結果插值得到。我們只不過簡單的用tex2D()進行了一下貼圖取樣,然後與光照強度混合得到最終結果。貼圖的顏色其實才是美術老師說的“物體材質的顏色”。
float4 PS ( VS_OUTPUT In ) : COLOR
{
float4 Tex = texture2D( TexSampler, In.Texcoord );
return Tex * In.Intensity + In.Spec;
}
總結
筆者已經進自己可能給大家講清楚了HLSL的一些基礎知識。從它的優點作用,到基本概念,到渲染流程,最後用個例子給大家串了一遍。
這隻是上篇,下一次,我們將看到:
l Shader是如何與你的程式進行互動的
l Shader使用的極致——Effect File
l 介紹多種編輯、編譯、調試Shader的工具
l 等等
參考文獻:
《Vertex Shader 結構》 (http://www.gameres.com/Articles/Program/Visual/3D/VertexShader.htm)
《Introduction to the DirectX 9 High-Level Shader Language》Craig Peeper Microsoft Corporation, Jason L. Mitchell ATI Research (http://msdn.microsoft.com/library/default.asp?url=/library/en-us/dnhlsl/html/shaderx2_introductionto.asp)
《ATI Radeon X800 3D Architecture white paper》