DirectX11--HLSL語法入門

前言

編寫本內容僅僅是爲了完善當前的教程體系,入門級別的內容其實基本上都是千篇一律,僅有一些必要細節上的擴充。要入門HLSL,只是掌握入門語法,即便把HLSL的全部語法也吃透了也並不代表你就能着色器代碼了,還需要結合到渲染管線中,隨着教程的不斷深入來不斷學習需要用到的新的語法,然後嘗試修改着色器,再根據實際需求自己編寫着色器來實現特定的效果。

注意:在翻閱HLSL文檔的時候,要避開Effects11相關的內容。因爲當前教程與Effects11是不兼容的。

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

數據類型

標量

常用標量類型如下:

類型 描述
bool 32位整數值用於存放邏輯值true和false
int 32位有符號整數
uint 32位無符號整數
half 16位浮點數(僅提供用於向後兼容)
float 32位浮點數
double 64位浮點數

注意:一些平臺可能不支持int, halfdouble,如果出現這些情況將會使用float來模擬

此外,浮點數還有規格化的形式:

  1. snorm float是IEEE 32位有符號且規格化的浮點數,表示範圍爲-1到1
  2. unorm float是IEEE 32位無符號且規格化的浮點數,表示範圍爲0到1

向量

向量類型可以支持2到4個同類元素

一種表示方式是使用類似模板的形式來描述

vector<float, 4> vec1;  // 向量vec1包含4個float元素
vector<int, 2> vec2;    // 向量vec2包含2個int元素

另一種方式則是直接在基本類型後面加上數字

float4 vec1;    // 向量vec1包含4個float元素
int3 vec2;      // 向量vec2包含3個int元素

當然,只使用vector本身則表示爲一種包含4個float元素的類型

vector vec1;    // 向量vec1包含4個float元素

向量類型有如下初始化方式:

float2 vec0 = {0.0f, 1.0f};
float3 vec1 = float3(0.0f, 0.1f, 0.2f);
float4 vec2 = float4(vec1, 1.0f);

向量的第1到第4個元素既可以用x, y, z, w來表示,也可以用r, g, b, a來表示。除此之外,還可以用索引的方式來訪問。下面展示了向量的取值和訪問方式:

float4 vec0 = {1.0f, 2.0f, 3.0f, 0.0f};
float f0 = vec0.x;  // 1.0f
float f1 = vec0.g;  // 2.0f
float f2 = vec0[2]; // 3.0f
vec0.a = 4.0f;      // 4.0f

我們還可以使用swizzles的方式來進行賦值,可以一次性提供多個分量進行賦值操作,這些分量的名稱可以重複出現:

float4 vec0 = {1.0f, 2.0f, 3.0f, 4.0f}; 
float3 vec1 = vec0.xyz;     // (1.0f, 2.0f, 3.0f)
float2 vec2 = vec0.rg;      // (1.0f, 2.0f)
float4 vec3 = vec0.zzxy;    // (4.0f, 4.0f, 1.0f, 2.0f)
vec3.wxyz = vec3;           // (2.0f, 4.0f, 4.0f, 1.0f)
vec3.yw = ve1.zz;           // (2.0f, 3.0f, 4.0f, 3.0f)

矩陣(matrix)

矩陣有如下類型(以float爲例):

float1x1 float1x2 float1x3 float1x4
float2x1 float2x2 float2x3 float2x4
float3x1 float3x2 float3x3 float3x4
float4x1 float4x2 float4x3 float4x4

此外,我們也可以使用類似模板的形式來描述:

matrix<float, 2, 2> mat1;   // float2x2

而單獨的matrix類型的變量實際上可以看做是一個包含了4個vector向量的類型,即包含16個float類型的變量。matrix本身也可以寫成float4x4

matrix mat1;    // float4x4

矩陣的初始化方式如下:

float2x2 mat1 = {
    1.0f, 2.0f, // 第一行
    3.0f, 4.0f  // 第二行
};
float3x3 TBN = float3x3(T, B, N); // T, B, N都是float3

矩陣的取值方式如下:

matrix M;
// ...

float f0 = M._m00;      // 第一行第一列元素(索引從0開始)
float f1 = M._12;       // 第一行第二列元素(索引從1開始)
float f2 = M[0][1];     // 第一行第二列元素(索引從0開始)
float4 f3 = M._11_12;   // Swizzles

矩陣的賦值方式如下:

matrix M;
vector v = {1.0f, 2.0f, 3.0f, 4.0f};
// ...

M[0] = v;               // 矩陣的第一行被賦值爲向量v
M._m11 = v[0];          // 等價於M[1][1] = v[0];和M._22 = v[0];
M._12_21 = M._21_12;    // 交換M[2][3]和M[3][2]

無論是向量還是矩陣,乘法運算符都是用於對每個分量進行相乘,例如:

float4 vec0 = 2.0f * float4(1.0f, 2.0f, 3.0f, 4.0f);    //(2.0f, 4.0f, 6.0f, 8.0f)
float4 vec1 = vec0 * float4(1.0f, 0.2f, 0.1f, 0.0f);    //(2.0f, 0.8f, 0.6f, 0.0f)

若要進行向量與矩陣的乘法,則需要使用mul函數。

在C++代碼層中,DirectXMath數學庫創建的矩陣都是行矩陣,但當矩陣從C++傳遞給HLSL時,HLSL默認是列矩陣的,看起來就好像傳遞的過程中進行了一次轉置那樣。如果希望不發生轉置操作的話,可以添加修飾關鍵字row_major

row_major matrix M;

數組

和C++一樣,我們可以聲明數組:

float M[4][4];
int p[4];
float3 v[12];   // 12個3D向量

結構體(struct)

HLSL的結構體和C/C++的十分相似,它可以存放任意數目的標量,向量和矩陣類型,除此之外,它還可以存放數組或者別的結構體類型。結構體的成員訪問也和C/C++相似:

struct A
{
    float4 vec;
};

struct B
{
    int scalar;
    float4 vec;
    float4x4 mat;
    float arr[8];
    A a;
};

// ...
B b;
b.vec = float4(1.0f, 2.0f, 3.0f, 4.0f);

變量的修飾符

關鍵字 含義
static 該着色器變量將不會暴露給C++應用層,需要在HLSL中自己初始化,否則使用默認初始化
extern 與static相反,該着色器變量將會暴露給C++應用層
uniform 該着色器變量允許在C++應用層被改變,但在着色器執行的過程中,其值始終保持不變(運行前可變,運行時不變)。着色器程序中的全局變量默認爲既uniform又extern
const 和C++中的含義相同,它是一個常量,需要被初始化且不可以被修改

類型轉換

HLSL有着極其靈活的類型轉換機制。HLSL中的類型轉換語法和C/C++的相同。下面是一些例子:

float f = 4.0f;
float4x4 m = (float4x4)f;   // 將浮點數f複製到矩陣m的每一個元素當中

float3 n = float3(...);
float3 v = 2.0f * n - 1.0f; // 這裏1.0f將會隱式轉換成(1.0f, 1.0f, 1.0f)

float4x4 WInvT = float4x4(...);
float3x3 mat = (float3x3)WInvT; // 只取4x4矩陣的前3行前3列

typedef關鍵字

和C++一樣,typedef關鍵字用來聲明一個類型的別稱:

typedef float3 point;           
typedef const float cfloat;

point p;    // p爲float3
cfloat f = 1.0f;    // f爲const float

運算符的一些特例

本教程不列出關鍵字,在學習的時候再逐漸接觸需要用到的會好一點。

C/C++中能用的運算符在HLSL中基本上都能用,也包括位運算。這裏只列出運算符的一些特例情況。

  1. 模運算符%不僅可以用於整數,還能用於浮點數。而且,要進行模運算就必須保證取模運算符左右操作數都具有相同的符號(要麼都爲正數,要麼都爲負數)。
  2. 基於運算符的向量間的運算都是以分量爲展開的。

例如:

float3 pos = {1.0f, 2.0f, 3.0f};
float3 p1 = pos * 2.0f;     // (2.0f, 4.0f, 6.0f)
float3 p2 = pos * pos;      // (1.0f, 4.0f, 9.0f)
bool3 b = (p1 == p2);       // (false, true, false)
++pos;                  // (2.0f, 3.0f, 4.0f)

因此,如果乘法運算符的兩邊都是矩陣,則表示爲矩陣的分量乘法,而不是矩陣乘法。

最後是二元運算中變量類型的提升規則:

  1. 對於二元運算來說,如果運算符左右操作數的維度不同,那麼維度較小的變量類型將會被隱式提升爲維度較大的變量類型。但是這種提升僅限於標量到向量的提升,即x會變爲(x, x, x)。但是不支持像float2float3的提升。
  2. 對於二元運算來說,如果運算符左右的操作數類型不同,那麼低精度變量的類型將被隱式提升爲高精度變量的類型,這點和C/C++是類似的。

控制流

條件語句

HLSL也支持if, else, continue, break, switch關鍵字,此外discard關鍵字用於像素着色階段拋棄該像素。

條件的判斷使用一個布爾值進行,通常由各種邏輯運算符或者比較運算符操作得到。注意向量之間的比較或者邏輯操作是得到一個存有布爾值的向量,不能夠直接用於條件判斷,也不能用於switch語句。

判斷與動態分支

基於值的條件分支只有在程序執行的時候被編譯好的着色器彙編成兩種方式:判斷(predication)動態分支(dynamic branching)

如果使用的是判斷的形式,編譯器會提前計算兩個不同分支下表達式的值。然後使用比較指令來基於比較結果來"選擇"正確的值。

而動態分支使用的是跳轉指令來避免一些非必要的計算和內存訪問。

着色器程序在同時執行的時候應當選擇相同的分支,以防止硬件在分支的兩邊執行。通常情況下,硬件會同時將一系列連續的頂點數據傳入到頂點着色器並行計算,或者是一系列連續的像素單元傳入到像素着色器同時運算等。

動態分支會由於執行分支指令所帶來的開銷而導致一定的性能損失,因此要權衡動態分支的開銷和可以跳過的指令數目。

通常情況下編譯器會自行選擇使用判斷還是動態分支,但我們可以通過重寫某些屬性來修改編譯器的行爲。我們可以在條件語句前可以選擇添加下面兩個屬性之一:

屬性 描述
[branch] 根據條件值的結果,只計算其中一邊的內容,會產生跳轉指令。默認不加屬性的條件語句爲branch型。
[flatten] 兩邊的分支內容都會計算,然後根據條件值選擇其中一邊。可以避免跳轉指令的產生。

用法如下:

[flatten]
if (x)
{
    x = sqrt(x);
}

循環語句

HLSL也支持for, whiledo while循環。和條件語句一樣,它可能也會在基於運行時的條件值判斷而產生動態分支,從而影響程序性能。如果循環次數較小,我們可以使用屬性[unroll]來展開循環,代價是產生更多的彙編指令。用法如下:

times = 4;
sum = times;
[unroll]
while (times--)
{
    sum += times;
}

若沒有添加屬性,默認使用的則爲[loop]

函數

函數的語法也和C/C++的十分類似,但它具有以下屬性:

  1. 參數只能按值傳遞
  2. 不支持遞歸
  3. 只有內聯函數(避免產生調用的跳轉來減小開銷)

此外,HLSL函數的形參可以指定輸入/輸出類別:

輸入輸出類別 描述
in 僅讀入。實參的值將會複製到形參上。若未指定則默認爲in
out 僅輸出。對形參修改的最終結果將會複製到實參上
inout 即in和out的組合

例如:

bool foo(in bool b,         // 輸入的bool類型參數
    out int r1,             // 輸出的int類型參數
    inout float r2)         // 具備輸入/輸出的float類型參數
{
    if (b)
    {
        f1 = 5;
    }
    else
    {
        r1 = 1;
    }
    
    // 注意r1不能出現在等式的右邊
    
    // r2既可以被讀入,也可以寫出結果到外面的實參上
    r2 = r2 * r2 * r2;
    
    return true;
}

內置函數

HLSL提供了一些內置全局函數,它通常直接映射到指定的着色器彙編指令集。這裏只列出一些比較常用的函數:

函數名 描述 最小支持着色器模型
abs 每個分量求絕對值 1.1
acos 求x分量的反餘弦值 1.1
all 測試x分量是否按位全爲1 1.1
any 測試x分量是否按位存在1 1.1
asdouble 將值按位重新解釋成double類型 5.0
asfloat 將值按位重新解釋成float類型 4.0
asin 求x分量的反正弦值 1.1
asint 將值按位重新解釋成int類型 4.0
asuint 將值按位重新解釋成uint類型 4.0
atan 求x分量的反正切值值 1.1
atan2 求(x,y)分量的反正切值 1.1
ceil 求不小於x分量的最小整數 1.1
clamp 將x分量的值限定在[min, max] 1.1
clip 丟棄當前像素,如果x分量的值小於0 1.1
cos 求x分量的餘弦值 1.1
cosh 求x分量的雙曲餘弦值 1.1
countbits 計算輸入整數的位1個數(對每個分量) 5.0
cross 計算兩個3D向量的叉乘 1.1
ddx 估算屏幕空間中的偏導數\(\partial \mathbf{p} / \partial x\)。這使我們可以確定在屏幕空間的x軸方向上,相鄰像素間某屬性值\(\mathbf{p}\)的變化量 2.1
ddy 估算屏幕空間中的偏導數\(\partial \mathbf{p} / \partial y\)。這使我們可以確定在屏幕空間的y軸方向上,相鄰像素間某屬性值\(\mathbf{p}\)的變化量 2.1
degrees 將x分量從弧度轉換爲角度制 1.1
determinant 返回方陣的行列式 1.1
distance 返回兩個點的距離值 1.1
dot 返回兩個向量的點乘 1.1
dst 計算距離向量 5.0
exp 計算e^x 1.1
exp2 計算2^x 1.1
floor 求不大於x分量的最大整數 1.1
fmod 求x/y的餘數 1.1
frac 返回x分量的小數部分 1.1
isfinite 返回x分量是否爲有限的布爾值 1.1
isinf 返回x分量是否爲無窮大的布爾值 1.1
isnan 返回x分量是否爲nan的布爾值 1.1
length 計算向量的長度 1.1
lerp 求x + s(y - x) 1.1
lit 返回一個光照係數向量(環境光亮度, 漫反射光亮度, 鏡面光亮度, 1.0f) 1.1
log 返回以e爲底,x分量的對數 1.1
log10 返回以10爲底,x分量的對數 1.1
log2 返回以2爲底,x分量的自然對數 1.1
mad 返回mvalue * avalue + bvalue 1.1
max 返回x分量和y分量的最大值 1.1
min 返回x分量和y分量的最小值 1.1
modf 將值x分開成整數部分和小數部分 1.1
mul 矩陣乘法運算 1
normalize 計算規格化的向量 1.1
pow 返回x^y 1.1
radians 將x分量從角度值轉換成弧度值 1
rcp 對每個分量求倒數 5
reflect 返回反射向量 1
refract 返回折射向量 1.1
reversebits 對每個分量進行位的倒置 5
round x分量進行四捨五入 1.1
rsqrt 返回1/sqrt(x) 1.1
saturate 對x分量限制在[0,1]範圍 1
sign 計算符號函數的值,x大於0爲1,x小於0爲-1,x等於0則爲0 1.1
sin 計算x的正弦 1.1
sincos 返回x的正弦和餘弦 1.1
sinh 返回x的雙曲正弦 1.1
smoothstep 給定範圍[min, max],映射到值[0, 1]。小於min的值取0,大於max的值取1 1.1
step 返回(x >= a) ? 1 : 0 1.1
tan 返回x的正切值 1.1
tanh 返回x的雙曲正切值 1.1
transpose 返回矩陣m的轉置 1
trunc 去掉x的小數部分並返回 1

語義

語義通常是附加在着色器輸入/輸出參數上的字符串。它在着色器程序的用途如下:

  1. 用於描述傳遞給着色器程序的變量參數的含義
  2. 允許着色器程序接受由渲染管線生成的特殊系統值
  3. 允許着色器程序傳遞由渲染管線解釋的特殊系統值

頂點着色器語義

輸入 描述 類型
BINORMAL[n] 副法線(副切線)向量 float4
BLENDINDICES[n] 混合索引 uint
BLENDWEIGHT[n] 混合權重 float
COLOR[n] 漫反射/鏡面反射顏色 float4
NORMAL[n] 法向量 float4
POSITION[n] 物體座標系下的頂點座標 float4
POSITIONT 變換後的頂點座標 float4
PSIZE[n] 點的大小 float
TANGENT[n] 切線向量 float4
TEXCOORD[n] 紋理座標 float4
Output 僅描述輸出 Type
FOG 頂點霧 float

n是一個可選的整數,從0開始。比如POSITION0, TEXCOORD1等等。

像素着色器語義

輸入 描述 類型
COLOR[n] 漫反射/鏡面反射顏色 float4
TEXCOORD[n] 紋理座標 float4
Output 僅描述輸出 Type
DEPTH[n] 深度值 float

系統值語義

所有的系統值都包含前綴SV_。這些系統值將用於某些着色器的特定用途(並未全部列出)

系統值 描述 類型
SV_Depth 深度緩衝區數據,可以被任何着色器寫入/讀取 float
SV_InstanceID 每個實例都會在運行期間自動生成一個ID。在任何着色器階段都能讀取 uint
SV_IsFrontFace 指定該三角形是否爲正面。可以被幾何着色器寫入,以及可以被像素着色器讀取 bool
SV_Position 若被聲明用於輸入到着色器,它描述的是像素位置,在所有着色器中都可用,可能會有0.5的偏移值 float4
SV_PrimitiveID 每個原始拓撲都會在運行期間自動生成一個ID。可用在幾何/像素着色器中寫入,也可以在像素/幾何/外殼/域着色器中讀取 uint
SV_StencilRef 代表當前像素着色器的模板引用值。只可以被像素着色器寫入 uint
SV_VertexID 每個實例都會在運行期間自動生成一個ID。僅允許作爲頂點着色器的輸入 uint

通用着色器的核心

所有的着色器階段使用通用着色器核心來實現相同的基礎功能。此外,頂點着色階段、幾何着色階段和像素着色階段則提供了獨特的功能,例如幾何着色階段可以生成新的圖元或刪減圖元,像素着色階段可以決定當前像素是否被拋棄等。下圖展示了數據是怎麼流向一個着色階段,以及通用着色器核心與着色器內存資源之間的關係:

Input Data:頂點着色器從輸入裝配階段獲取數據;幾何着色器則從上一個着色階段的輸出獲取等等。通過給形參引入可以使用的系統值可以提供額外的輸入

Output Data:着色器生成輸出的結果然後傳遞給管線的下一個階段。有些輸出會被通用着色器核心解釋成特定用途(如頂點位置、渲染目標對應位置的值),另外一些輸出則由應用程序來解釋。

Shader Code:着色器代碼可以從內存讀取,然後用於執行代碼中所期望的內容。

Samplers:採樣器決定了如何對紋理進行採樣和濾波。

Textures:紋理可以使用採樣器進行採樣,也可以基於索引的方式按像素讀取。

Buffers:緩衝區可以使用讀取相關的內置函數,在內存中按元素直接讀取。

Constant Buffers:常量緩衝區對常量值的讀取有所優化。他們被設計用於CPU對這些數據的頻繁更新,因此他們有額外的大小、佈局和訪問限制。

着色器常量

着色器常量存在內存中的一個或多個緩衝區資源當中。他們可以被組織成兩種類型的緩衝區:常量緩衝區(cbuffers)和紋理緩衝區(tbuffers)。關於紋理緩衝區,我們不在這討論。

常量緩衝區(Constant Buffer)

常量緩衝區允許C++端將數據傳遞給HLSL中使用,在HLSL端,這些傳遞過來的數據不可更改,因而是常量。常量緩衝區對這種使用方式有所優化,表現爲低延遲的訪問和允許來自CPU的頻繁更新,因此他們有額外的大小、佈局和訪問限制。

聲明方式如下:

cbuffer VSConstants
{
    float4x4 g_WorldViewProj;
    fioat3 g_Color;
    uint g_EnableFog;
    float2 g_ViewportXY;
    float2 g_ViewportWH;
}

由於我們寫的是原生HLSL,當我們在HLSL中聲明常量緩衝區時,還需要在HLSL的聲明中使用關鍵字register手動指定對應的寄存器索引,然後編譯器會爲對應的着色器階段自動將其映射到15個常量緩衝寄存器的其中一個位置。這些寄存器的名字爲b0b14

cbuffer VSConstants : register(b0)
{
    float4x4 g_WorldViewProj;
    fioat3 g_Color;
    uint g_EnableFog;
    float2 g_ViewportXY;
    float2 g_ViewportWH;
}

在C++端是通過ID3D11DeviceContext::*SSetConstantBuffers指定特定的槽(slot)來給某一着色器階段對應的寄存器索引提供常量緩衝區的數據。

如果是存在多個不同的着色器階段使用同一個常量緩衝區,那就需要分別給這兩個着色器階段設置好相同的數據。

綜合前面幾節內容,下面演示了頂點着色器和常量緩衝區的用法:

cbuffer ConstantBuffer : register(b0)
{
    float4x4 g_WorldViewProj;
}


void VS_Main(
    in float4 inPos : POSITION,         // 綁定變量到輸入裝配器
    in uint VID : SV_VertexID,          // 綁定變量到系統生成值
    out float4 outPos : SV_Position)    // 告訴管線將該值解釋爲輸出的頂點位置
{
    outPos = mul(inPos, g_WorldViewProj);
}

上面的代碼也可以寫成:

cbuffer ConstantBuffer : register(b0)
{
    float4x4 g_WorldViewProj;
}

struct VertexIn
{
    float4 inPos : POSITION;    // 源自輸入裝配器
    uint VID : SV_VertexID;     // 源自系統生成值
};

float4 VS_Main(VertexIn vIn) : SV_Position
{
    return mul(vIn.inPos, g_WorldViewProj);
}

有關常量緩衝區的打包規則,建議在閱讀到時索引緩衝區、常量緩衝區一章時,再來參考雜項篇的HLSL常量緩衝區的打包規則。

DirectX11 With Windows SDK完整目錄

Github項目源碼

歡迎加入QQ羣: 727623616 可以一起探討DX11,以及有什麼問題也可以在這裏彙報。

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