编写本内容仅仅是为了完善当前的教程体系,入门级别的内容其实基本上都是千篇一律,仅有一些必要细节上的扩充。要入门HLSL,只是掌握入门语法,即便把HLSL的全部语法也吃透了也并不代表你就能着色器代码了,还需要结合到渲染管线中,随着教程的不断深入来不断学习需要用到的新的语法,然后尝试修改着色器,再根据实际需求自己编写着色器来实现特定的效果。

注意:在翻阅HLSL文档的时候,要避开Effects11相关的内容。因为当前教程与Effects11是不兼容的。

DirectX11 With Windows SDK完整目录

Github项目源码

欢迎加入QQ群: 727623616 可以一起探讨DX11,以及有什么问题也可以在这里汇报。

常用标量类型如下:

一种表示方式是使用类似模板的形式来描述

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;    // (3.0f, 3.0f, 1.0f, 2.0f)
vec3.wxyz = vec3;           // (3.0f, 1.0f, 2.0f, 3.0f)
vec3.yw = vec1.yy;           // (3.0f, 2.0f, 2.0f, 2.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开始)
float2 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[0][1]和M[1][0]

无论是向量还是矩阵,乘法运算符都是用于对每个分量进行相乘,例如:

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];
// ...
b.vec = float4(1.0f, 2.0f, 3.0f, 4.0f);

变量的修饰符

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中基本上都能用,也包括位运算。这里只列出运算符的一些特例情况。

  • 模运算符%不仅可以用于整数,还能用于浮点数。而且,要进行模运算就必须保证取模运算符左右操作数都具有相同的符号(要么都为正数,要么都为负数)。
  • 基于运算符的向量间的运算都是以分量为展开的。
  • 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)
    

    因此,如果乘法运算符的两边都是矩阵,则表示为矩阵的分量乘法,而不是矩阵乘法。

    最后是二元运算中变量类型的提升规则:

  • 对于二元运算来说,如果运算符左右操作数的维度不同,那么维度较小的变量类型将会被隐式提升为维度较大的变量类型。但是这种提升仅限于标量到向量的提升,即x会变为(x, x, x)。但是不支持像float2float3的提升。
  • 对于二元运算来说,如果运算符左右的操作数类型不同,那么低精度变量的类型将被隐式提升为高精度变量的类型,这点和C/C++是类似的。
  • HLSL也支持if, else, continue, break, switch关键字,此外discard关键字用于像素着色阶段抛弃该像素。

    条件的判断使用一个布尔值进行,通常由各种逻辑运算符或者比较运算符操作得到。注意向量之间的比较或者逻辑操作是得到一个存有布尔值的向量,不能够直接用于条件判断,也不能用于switch语句。

    判断与动态分支

    基于值的条件分支只有在程序执行的时候被编译好的着色器汇编成两种方式:判断(predication)动态分支(dynamic branching)

    如果使用的是判断的形式,编译器会提前计算两个不同分支下表达式的值。然后使用比较指令来基于比较结果来"选择"正确的值。

    而动态分支使用的是跳转指令来避免一些非必要的计算和内存访问。

    着色器程序在同时执行的时候应当选择相同的分支,以防止硬件在分支的两边执行。通常情况下,硬件会同时将一系列连续的顶点数据传入到顶点着色器并行计算,或者是一系列连续的像素单元传入到像素着色器同时运算等。

    动态分支会由于执行分支指令所带来的开销而导致一定的性能损失,因此要权衡动态分支的开销和可以跳过的指令数目。

    通常情况下编译器会自行选择使用判断还是动态分支,但我们可以通过重写某些属性来修改编译器的行为。我们可以在条件语句前可以选择添加下面两个属性之一:

    HLSL也支持for, whiledo while循环。和条件语句一样,它可能也会在基于运行时的条件值判断而产生动态分支,从而影响程序性能。如果循环次数较小,我们可以使用属性[unroll]来展开循环,代价是产生更多的汇编指令。用法如下:

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

    若没有添加属性,默认使用的则为[loop]

    函数的语法也和C/C++的十分类似,但它具有以下属性:

  • 参数只能按值传递
  • 不支持递归
  • 只有内联函数(避免产生调用的跳转来减小开销)
  • 此外,HLSL函数的形参可以指定输入/输出类别:

    输入输出类别
    bool foo(in bool b,			// 输入的bool类型参数
    	out int r1,				// 输出的int类型参数
    	inout float r2)			// 具备输入/输出的float类型参数
        if (b)
            f1 = 5;
            r1 = 1;
        // 注意r1不能出现在等式的右边
        // r2既可以被读入,也可以写出结果到外面的实参上
        r2 = r2 * r2 * r2;
        return true;
    

    HLSL提供了一些内置全局函数,它通常直接映射到指定的着色器汇编指令集。这里只列出一些比较常用的函数:

    最小支持着色器模型 估算屏幕空间中的偏导数\(\partial \mathbf{p} / \partial x\)。这使我们可以确定在屏幕空间的x轴方向上,相邻像素间某属性值\(\mathbf{p}\)的变化量 估算屏幕空间中的偏导数\(\partial \mathbf{p} / \partial y\)。这使我们可以确定在屏幕空间的y轴方向上,相邻像素间某属性值\(\mathbf{p}\)的变化量 degrees 将x分量从弧度转换为角度制 determinant 返回方阵的行列式 distance 返回两个点的距离值 返回两个向量的点乘 计算距离向量 计算e^x 计算2^x floor 求不大于x分量的最大整数 求x/y的余数 返回x分量的小数部分 isfinite 返回x分量是否为有限的布尔值 isinf 返回x分量是否为无穷大的布尔值 isnan 返回x分量是否为nan的布尔值 length 计算向量的长度 求x + s(y - x) 返回一个光照系数向量(环境光亮度, 漫反射光亮度, 镜面光亮度, 1.0f) 返回以e为底,x分量的对数 log10 返回以10为底,x分量的对数 返回以2为底,x分量的自然对数 返回mvalue * avalue + bvalue 返回x分量和y分量的最大值 返回x分量和y分量的最小值 将值x分开成整数部分和小数部分 矩阵乘法运算 normalize 计算规格化的向量 返回x^y radians 将x分量从角度值转换成弧度值 对每个分量求倒数 reflect 返回反射向量 refract 返回折射向量 reversebits 对每个分量进行位的倒置 round x分量进行四舍五入 rsqrt 返回1/sqrt(x) saturate 对x分量限制在[0,1]范围 计算符号函数的值,x大于0为1,x小于0为-1,x等于0则为0 计算x的正弦 sincos 返回x的正弦和余弦 返回x的双曲正弦 smoothstep 给定范围[min, max],映射到值[0, 1]。小于min的值取0,大于max的值取1 返回(x >= a) ? 1 : 0 返回x的正切值 返回x的双曲正切值 transpose 返回矩阵m的转置 trunc 去掉x的小数部分并返回

    语义通常是附加在着色器输入/输出参数上的字符串。它在着色器程序的用途如下:

  • 用于描述传递给着色器程序的变量参数的含义
  • 允许着色器程序接受由渲染管线生成的特殊系统值
  • 允许着色器程序传递由渲染管线解释的特殊系统值
  • 顶点着色器语义

    通用着色器的核心

    所有的可编程着色器阶段使用通用着色器核心来实现相同的基础功能。此外,顶点着色阶段、几何着色阶段和像素着色阶段则提供了独特的功能,例如几何着色阶段可以生成新的图元或删减图元,像素着色阶段可以决定当前像素是否被抛弃等。下图展示了数据是怎么流向一个着色阶段,以及通用着色器核心与着色器内存资源之间的关系:

    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,以及有什么问题也可以在这里汇报。

    作者:X_Jun
    本文版权归X_Jun(博客园)所有(CSDN为x_jun96),欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接,否则保留追究法律责任的权利。