新建项目 “005-DrawBlock”
线性代数
线性代数是数学的一个分支,是图形学的基础,它的研究对象是向量,向量空间 (线性空间),线性变换和有限维的线性方程组 (矩阵代数) 以及有关这些的 线性问题。
很多游戏行业的小伙伴对图形学抱有兴趣,但面对线性代数这座大山,就变得愁眉苦脸了。其实线代是非常美丽的工具,图形学的变换都可以用线代工具简洁美观地表达。本节教程我们用一种更通俗易懂的方式来理解线性代数,让傻子也能学会线代。
矩阵
|
---|
我们先谈本章的重头戏:Matrix 矩阵。
矩阵,顾名思义,是一个由多个数字组合起来的方阵 (如下图):
那么数学家们为什么要发明矩阵呢?
初中我们学过解二元一次方程组,在几何中,解二元一次方程组实际上是 求两条直线的交点:
我们很容易通过 加减消元 或 代入消元 解出方程组:
加减消元 和 代入消元 本质都是 高斯消元法 —— 减少未知数的数量,用另一未知数的表达式代替一个未知数。高斯消元法可用于线性方程组求解。
线性方程组又是什么?例如上面的二元一次方程,每条方程在几何空间都可以表示成一条直线,每条方程都可以通过一系列转换,变成 y=ax+b 这种成直线,按比例变化的形式,每条方程的一阶导数均为常数,这样的方程组就叫线性方程组。
我们初中还学过三元一次方程,三元一次方程也是线性方程,三元一次方程组在空间中表示什么呢?每条方程 a x + b y + c z + d = 0 \boldsymbol{ax+by+cz+d=0} ax+by+cz+d=0 表示一个平面,而三元一次方程组的解表示三个平面的相交点。
对于三元一次方程,我们同样可以通过高斯消元法求解,但是计算量明显比二元一次方程组要大得多。生活中我们还会见到四元一次方程组,五元一次方程组…等等这些线性方程组,一个一个消元代入求解的话岂不是很麻烦?
有没有什么方法,能使线性方程组求解变得简单呢?1855年,英国数学家 阿瑟·凯莱 出于简化线性方程组求解的动机,研究高斯消元法后得到了一个惊人的结论:线性方程组都可以简单的拆解成三部分,然后对这三部分进行简单标记后,计算就更容易了。由此他引入了 Matrix 矩阵 的概念。没错,矩阵最早是为线性方程组求解而生的。
这位数学家是怎么进行拆解与标记呢?他先把未知量都移到一个方括号里,让它们组成一个矩阵:
然后再把未知量的系数和等号右边的数字分别组成矩阵,这样就得到了矩阵形式的方程组:
这位数学家同时还发表了大量关于矩阵的研究文章,引入了许多重要的矩阵概念和理论,这样就能通过 矩阵的运算法则 更容易的求解线性方程组了。不管你是三元,四元,五元还是 N 元方程组,只要你是 线性方程组,我都可以用 矩阵 表示和计算。
矩阵加减
两个矩阵也可以进行加减法,矩阵加减相当于矩阵各元素两两相加减,前提是两个矩阵的行数和列数必须相同,不相同就不能加减:
矩阵满足加法交换律和加法结合律。
矩阵乘法
|
---|
上文提到的方程组改写成矩阵,就是依靠 矩阵乘法 实现的。
矩阵可以看作由向量构成的方阵:
矩阵相乘相当于一个矩阵的行向量与另一个矩阵的列向量两两相乘,得到由向量点积构成的新矩阵。矩阵相乘需要左边矩阵的列数与右边矩阵的行数相等,否则就不可以相乘:
左矩阵的列数等于右矩阵的行数,才可以进行矩阵乘法,否则无意义!矩阵乘法的结果仍然是一个矩阵 (行数为左矩阵行数,列数为右矩阵列数的矩阵)。
和向量一样,矩阵也可以进行标量乘法:
标量乘法满足乘法交换律,结合律和分配律。但请注意!矩阵之间的乘法只满足结合律,不满足交换律!(原因:矩阵相乘的条件限制了交换律)
单位矩阵
在矩阵的乘法中,有一种矩阵起着特殊的作用,如同数的乘法中的 1 1 1,这种矩阵被称为单位矩阵,用 E \boldsymbol{E} E 表示。它是个 n × n \boldsymbol{n \times n} n×n 的正方矩阵,行数与列数相等,从左上角到右下角的对角线(称为主对角线)上的元素均为 1 1 1。除此以外全都为 0 0 0。
在矩阵乘法有意义的情况下,任何矩阵与单位矩阵相乘都等于本身,所以广泛用于线性代数。
矩阵的秩
我们现在可以通过矩阵来表示方程组了,然而现实中我们可能要计算上千、上万个矩阵,这些矩阵有可能只有一个解,也有可能无解,强行硬算会增加无效的计算量,数学家们需要找到一种快捷方法,来快速判断方程组能不能求。那如何快速知道这个方程组有几个解呢?
一个方程组可能只有一个解,可能有无数个解,也可能无解。如何只通过矩阵就能判断解的情况呢?为了解决这个问题,德国数学家 Frobenius 在 1879 年引入了 Rank 秩 的概念。
矩阵的秩是一个非负整数,表示矩阵中线性无关的行向量 (或列向量) 的最大数目,你可以理解为矩阵的真实等级,用 R a n k ( A ) {Rank(A)} Rank(A) 表示:
线性无关又是啥东西呢?前文我们提到过,矩阵是可以表示成多个向量组成的方阵的。我们可以借助向量和几何空间来理解"线性无关"与"秩"。下面是一个 2 × 2 2 \times 2 2×2 矩阵,它可以表示成两个二维向量组成的方阵:
"秩"代表了矩阵的真实等级。如果我们修改向量 v \boldsymbol{v} v,使它与向量 u \boldsymbol{u} u 共线,那么 u \boldsymbol{u} u, v \boldsymbol{v} v 就会在同一条直线上,矩阵的秩就会降级。
同一条直线意味着什么?意味着向量 v \boldsymbol{v} v 可以被 u \boldsymbol{u} u 表示,所以向量 v \boldsymbol{v} v 与向量 u \boldsymbol{u} u 线性相关。线性无关指的是一组向量中任意一个向量都不能由其它几个向量线性表示。向量 v \boldsymbol{v} v 可以被 u \boldsymbol{u} u 表示,说明它在矩阵中是多余的,有人可以替代它的工作,所以线性相关。
|
---|
矩阵的秩包含了矩阵的有效信息,表示矩阵里有多少个不可替代的线性无关向量。
可以类比明日方舟,ew 和叔叔一出,银老板和牢香就失业了,维什戴尔,玛恩纳,银灰,迷迭香组成的矩阵中,ew 和叔叔组成的向量是矩阵的有效信息,不可替代;银灰和迷迭香组成的向量是冗余信息,可以被 ew 和叔叔替代,所以它们不影响矩阵的秩。
如果我们拓展到 3 × 3 \boldsymbol{3 \times 3} 3×3 矩阵呢?例如下面的图,矩阵里面的三个向量是线性无关的,所以矩阵的秩为 3 \boldsymbol{3} 3:
但如果三个向量共线或共面呢?那么线性无关的向量就会减少,矩阵就会降秩:
所以秩也可以表示矩阵可以表达的空间维度。
说了那么多,数学家们如何获得矩阵的秩?
答案:初等变换。
初等变换本质上是方程组的交换和加减消元,是高斯消元法的拓展,包括:
- 交换矩阵的行或列
- 用一个数 K 乘以某一行。
- 用某个数乘以某一行加到另一行中去。
我们通过初等行变换将矩阵化简为阶梯形。这可以更清晰地看到哪些行是线性无关的,从而确定秩。
我们很容易看出来,这个矩阵有两个线性无关的向量,所以这个矩阵的秩为 2,说明两个未知数 x1、x2 都可以被唯一确定,方程组只有一个解
但如果矩阵里面有线性相关的变量呢?那么经过初等变换后,就会出现零向量:
这个矩阵只有一个线性无关的向量 (x1 所在行),所以这个矩阵的秩为 1,说明只能确定一个未知数,方程组可以有无数个解
通过上面对矩阵秩的研究,数学家们得出了一个结论:
如果矩阵的秩恰好等于矩阵的阶数,那么方程组只有一个解;反之,矩阵秩小于阶数,则方程组无解,或有无数个解。
矩阵的秩不仅可以用于线性代数,还在控制理论、统计学、图像处理等领域有广泛应用。
行列式
|
---|
行列式的由来
矩阵秩只能说明方程组解的情况,如果方程组有解,那么它的解到底是什么呢?
一般地,对于二元一次线性方程组,它的解是:
对于三元一次线性方程组,它的解是:
n n n 元一次线性方程组的解可想而知,会更加复杂。
简化 n n n 元一次线性方程组的解,找出其中的规律,在这个过程中产生了行列式。
行列式用 d e t ( A ) \boldsymbol{det(A)} det(A) 或把矩阵两边的括号改成竖直线表示:
行列式是一个可以为负的数。图形学只会用到 n × n \boldsymbol{n \times n} n×n 矩阵 ( n \boldsymbol{n} n 阶矩阵,矩阵行列数相等 ) 的行列式!
行列数不相等的矩阵的行列式,称为广义行列式。这玩意很复杂,通常用于更深层次的数学研究,图形学不会用到它,所以我们不讨论,了解一下就行。
二阶和三阶矩阵的行列式,均可以用对角线法则求出来:
观察上面,我们可以发现,二阶行列式的项数是 2 ! = 2 \boldsymbol{2! = 2} 2!=2,三阶行列式的项数是 3 ! = 6 \boldsymbol{3! = 6} 3!=6,数学家们通过大量的研究与验证,得出一个结论: n \boldsymbol{n} n 阶行列式的项数均为 n ! \boldsymbol{n!} n! ( n \boldsymbol{n} n 的阶乘 )。
但是对于三阶以上的高阶行列式,对角线法则就不管用了。以四阶行列式为例,四阶行列式展开后,其项数一共有 4 ! = 24 \boldsymbol{4! = 24} 4!=24 项,但根据对角线法则来算只有 8 \boldsymbol{8} 8 项,说明对角线法则并不普遍适用于行列式。
行列式的定义
N 阶行列式该如何定义呢?数学家们(应该是凯莱、范德蒙这些先驱)废了无数草稿纸,反复验算各阶线性方程组,从中总结出来的:通过全排列和逆序数定义三阶行列式。
前置知识:全排列与逆序数
有如下三个数字:
总共有以下 6 种不重复的排列方式:
把 n 个不同的元素排成一列,全部不重复的排列方式,叫做这 n 个元素的全排列 (简称排列)。
比如有这么一个数列:
规定:从小到大为正序,否则为逆序。比如:
上图中可以看出,没有一个逆序的,因为 5 是第三个数字,所以用下列的符号 t 3 {t}_{3} t3 来表示没有逆序:
再比如:
数列内所有的逆序数为:
逆序数定义为:
在一个排列中,如果一对数的前后位置与大小顺序相反,即前面的数大于后面的数,那么它们就称为一个逆序。一个排列中逆序的总数就称为这个排列的逆序数。
有了全排列和逆序数,就可以来定义行列式了。
以三阶行列式为例:
来观察每一项的脚标,脚标第一项都是按照" 1 , 2 , 3 {1,2,3} 1,2,3 "排列的:
而脚标的第二项是" 1 , 2 , 3 {1,2,3} 1,2,3 "的全排列:
正负号怎么来的呢?是由逆序数决定的:
因此,三阶行列式可以定义为:
其中, t t t 为排列 p 1 p 2 p 3 {p_1p_2p_3} p1p2p3 的逆序数, ∑ \sum ∑ 表示对“ 1 , 2 , 3 1,2,3 1,2,3 ”的所有排列“ p 1 p 2 p 3 p_1p_2p_3 p1p2p3 ”求和。
同理,全排列的项数都是 n ! \boldsymbol{n!} n!,保证了这种定义比对角线法则更泛用,可以推广到 n \boldsymbol{n} n 阶行列式:
克莱姆法则与方程求解
行列式对方程求解有什么用呢?这就是下面我们要讲的克莱姆法则了:
让行列式成为数学界的共识,是行列式的历史源头。
观察二元一次方程组的解 x 1 x 2 x_1 x_2 x1x2 的特点, x 1 x 2 x_1 x_2 x1x2 可以用行列式表示:
三元一次方程组同理:
观察它们的解,注意标红部分,你能发现什么规律吗?
可以看到如下规律:
- 分母都是系数组成的行列式。
- 分子也是系数组成的行列式,只是对应于不同的 x i x_i xi ,第 i i i 列被替换成等号右边的常数项了。
推广到 n n n 元线性方程组的话,就是克莱姆法则:
为什么要用“全排列”、“逆序数”这么晦涩的名词来定义行列式?完全是因为只有这样定义,克拉默法则才可以成立。
行列式在图形学的意义
|
---|
行列式对于图形学有什么意义呢?
首先,行列式可以用来表示向量组在空间中形成的有向面积/体积:
下面我们还会学到 线性变换,简单来说,就是对图形进行 平移、旋转、缩放、投影、斜切等等 这些变换,这些变换都有各自的 矩阵表示形式,而它们的行列式表示了变换对图形面积/体积的变化量:
行列式与矩阵的秩的关系
有了行列式,我们就能快速判断矩阵的秩了:当矩阵行列式为 0 时,那么这个矩阵一定不是满秩矩阵,方程组可能有无穷多解或无解。
转置矩阵
将矩阵的行列互换得到的新矩阵称为转置矩阵:
转置矩阵具有以下性质:
逆矩阵
除法是乘法的逆运算,除一个数相当于乘这个数的一分之几:
矩阵运算有矩阵乘法,那么是否也有"矩阵除法"呢?逆矩阵就是矩阵乘法的逆运算,用 A − 1 {A}^{-1} A−1 表示:
原矩阵 A {A} A 与它的逆矩阵 A − 1 {A}^{-1} A−1 的乘积是一个单位矩阵 E {E} E ( A A − 1 = E {A}{A}^{-1} = E AA−1=E),所以逆矩阵是矩阵乘法的逆运算。一个矩阵如果可逆,那么它的逆矩阵是唯一的。
那么如何得到一个矩阵的逆矩阵呢?
首先,我们需要计算出矩阵的行列式,行列式为 0 \boldsymbol{0} 0 的矩阵是没有逆矩阵的,这类不可逆的矩阵叫奇异矩阵。
如果矩阵可以求逆,我们需要求出它的伴随矩阵 A ∗ {A^*} A∗,然后根据下面这个公式就可以求出逆矩阵了:
那么什么是伴随矩阵呢?
在讲这个之前,我们先来了解一个东西:代数余子式。
代数余子式,说白了,它和行列式一样,也是一个常数。代数余子式用 M i j {M}_{ij} Mij 表示,去除矩阵 A A A 中 A i j {A}_{ij} Aij 所在的第 i i i 行和第 j j j 列所有元素,将剩余的元素组成一个新矩阵 M M M ,求出 M M M 的行列式,这个行列式就是 A i j {A}_{ij} Aij 的代数余子式 ( M i j = ( − 1 ) i + j d e t A ‾ i j \bm{{M}_{ij}={(-1)}^{i+j}det \space {\overline{A}_{ij}}} Mij=(−1)i+jdet Aij):
把 A A A 中所有元素的代数余子式组合成一个新矩阵 M M M,这个矩阵 M M M 的转置矩阵 M T M^T MT 就是 A A A 的伴随矩阵 A ∗ A^* A∗ ( A ∗ = M T \bm{A^*=M^T} A∗=MT):
然后我们就可以用 A − 1 = A ∗ d e t ( A ) \bm{{A}^{-1}= \frac{A^*}{det(A)}} A−1=det(A)A∗ 求出 A 的逆矩阵了:
计算机是如何求行列式和逆矩阵的?
虽然二阶和三阶行列式求解起来非常简单——分解出的项数很少,完全可以通过对角线法则求出,但是四阶行列式开始,项数就呈阶乘级增长,到了 10 阶行列式就是天文数字了,如果我们直接用行列式的定义 (逆序数+全排列) 来求解,它的时间复杂度是 O ( n ! ) \bm{O(n!)} O(n!),这得解到猴年马月?
为了解决这一问题,计算机科学家们发现有一类特殊的正方矩阵,它的主对角线左下侧全为 0,行列式恰好是主对角线所有元素的乘积。这种矩阵叫上三角矩阵:
既然上三角矩阵求行列式这么方便,那我们可以将矩阵转换为上三角矩阵啊!
再加上数学家们发现了初等变换影响行列式的规律:
因此我们可以先对矩阵进行初等变换,让它变成等价的上三角矩阵,然后就可以很方便求出行列式了。注意每一次初等变换后,行列式也要进行变换:
它的时间复杂度是 O ( n 2 ) \bm{O(n^2)} O(n2),是不是快多了?这是计算机常用的一个求行列式算法。
至于求逆矩阵,也不是通过伴随矩阵法来求 (时间复杂度 O ( n 4 ) \bm{O(n^4)} O(n4)),仍然是通过上三角矩阵和下三角矩阵来求。数学家们发现,一个可逆矩阵可以分解成上三角矩阵与下三角矩阵的乘积,然后通过 A − 1 = ( L U ) − 1 = U − 1 L − 1 \bm{{A}^{-1}=(LU)^{-1}=U^{-1}L^{-1}} A−1=(LU)−1=U−1L−1 这条性质来快速求逆矩阵:
这个方法最巧妙的一点是,可以套线性公式快速计算:
这个方法最大的计算量来自两个逆矩阵 L − 1 {L}^{-1} L−1 和 U − 1 {U}^{-1} U−1 的相乘,这个方法之所以会成为主流方法,原因是逆矩阵相乘可以方便 GPU 并行计算,可以方便的切割矩阵。
这种算法叫 LU分解法,时间复杂度是 O ( n 3 ) \bm{O(n^3)} O(n3) ,是计算机最常用的矩阵求逆算法。
除了 LU 分解法,还有 SVD 分解,QR 分解算法等等。
向量
向量是有方向,有大小的量,在几何中相当于一条带箭头的线段。
而向量的大小叫向量的模,通常用 ∣ a ⃗ ∣ \bm{|\vec a|} ∣a∣ 表示:
向量加减
两个向量相加会得到一个新的向量,向量的相加符合三角形法则和平行四边形法则;向量的减法相当于减向量的终点指向被减向量的终点,向量相减同样会得到一个新的向量,符合三角形法则。
在坐标表示中,向量加减是直接对分量逐一加减的:
向量点积 (内积、数量积)
既然矩阵乘法可以看做各向量之间的相乘,那它们相乘的结果有什么意义呢?
两个向量 a ⃗ , b ⃗ \bm{\vec a, \vec b} a,b 之间相乘得到一个标量 W W W,那么这种乘法叫向量的点积 (也叫内积,数量积),用 a ⃗ ⋅ b ⃗ \bm{\vec a \cdot \vec b} a⋅b 表示:
向量点积为 0 时,两向量垂直:
向量点积表示了两个向量之间的数量关系。向量点积广泛用于各种领域,不仅可以用于向量投影和矩阵乘法,还可以用于物理学的做功分析,我们可以用向量点积来表示这个力在物体位移的方向上做了多少有效功:
向量叉积 (外积、向量积)
在图形学中我们常常遇到求平面法向量的情况,如果两个向量 u ⃗ , v ⃗ \bm{\vec u , \vec v} u,v 可以构成一个平面,要求同时垂直于 u ⃗ , v ⃗ \bm{\vec u , \vec v} u,v 的向量,我们可以用向量的叉积来求:
向量的叉积 (外积、向量积) 用 a ⃗ × b ⃗ \bm{\vec a \times \vec b} a×b 表示,注意叉积不是点积!叉积求得的仍然是一个向量,并且这个向量既垂直于 a ⃗ \bm{\vec a} a,也垂直于 b ⃗ \bm{\vec b} b:
注意!叉乘顺序取决于使用左手还是右手规则,图形学一般使用右手规则:
图形学只讨论 3D 向量的叉积。因为叉积一般都用来判断图形包含关系,判断顶点绕序方向和求平面法向量。
叉乘不满足交换律,但满足反交换律,即 a ⃗ × b ⃗ = − ( b ⃗ × a ⃗ ) \bm{\vec a \times \vec b = -(\vec b \times \vec a)} a×b=−(b×a) (向量交换后,叉乘得到的向量反向)。
单位向量
模为 1 的向量叫单位向量。对于任何非零向量 u ⃗ \bm{\vec u} u,与 u ⃗ \bm{\vec u} u 同方向的单位向量叫 u ⃗ \bm{\vec u} u 的单位向量,用 u 0 ⃗ \bm{\vec {u^0}} u0 或 u ^ \bm{\hat u} u^ 表示:
向量的投影
在生活与游戏中,我们常常见到各种各样的影子,这些都叫投影现象。
向量也有投影。我们把某一向量 b ⃗ \bm{\vec b} b 在另一个向量 a ⃗ \bm{\vec a} a 上的投影叫做 b ⃗ \bm{\vec b} b 在 a ⃗ \bm{\vec a} a 上的投影向量,记作 P r o j a ⃗ ( b ⃗ ) \bm{{Proj}_{\vec a}(\vec b)} Proja(b):
线性变换
左手坐标系与右手坐标系
|
---|
程序员常常因为 3D 模型的顶点与纹理朝向不同而饱受折磨,是因为图形 API 使用的坐标系不同。
不同于 OpenGL 与 Vulkan 的右手坐标系,DirectX 使用的是左手坐标系。现在伸出你的左手,像图示一样,大拇指往前伸 (代表 x \bm{x} x 轴),食指往上伸 (代表 y \bm{y} y 轴),中指往前伸 (代表 z \bm{z} z 轴),那么这个就是左手坐标系了。
这个时候你可能会问:我们在高中学过的三维坐标系不是 z \bm{z} z 轴朝上吗?怎么到左右手坐标系就变成 y \bm{y} y 轴朝上了?这是因为渲染就是将 3D 变成 2D 的过程,屏幕坐标系就是由 x \bm{x} x 轴和 y \bm{y} y 轴组成的,规定 y \bm{y} y 轴朝上、 x \bm{x} x 轴朝右,让三维坐标系的 x \bm{x} x 轴和 y \bm{y} y 轴与屏幕坐标系重合,就不用再进行繁琐的坐标变换了,可以节省很多不必要的计算量。
右手坐标系与左手坐标系的区别是仅仅反转了一个轴的方向,在写顶点数据时请多动动你的手!不然可能会出现纹理错位或者 3D 模型 “横着摆”、“倒着摆” 的情况!
线性变换是什么
|
---|
在图形学中,我们经常需要平移一个物体:
旋转一个物体:
甚至放大/缩小这个物体:
上面这些变换都可以用矩阵表示:
为什么这些变换都可以用矩阵表示呢?为什么要用矩阵表示呢?
来,现在让我们回到二维空间。下面是一个二维向量:
我们可以对这个向量乘以 2,放大这个向量:
这个放大过程可以用矩阵表示,我们把 x , y \bm{x,y} x,y 的系数都分离到一个二阶矩阵中,这个矩阵与向量相乘,就可以得到放大后的向量了:
但如果我们对这个向量进行不等比放大呢?我们对这个向量,向 x \bm{x} x 轴方向放大 5 倍,向 y \bm{y} y 轴方向放大 3 倍,这种变换又如何转换成矩阵呢?
看清楚上面的动图吗?我们可以把每个坐标分量写成 a x + b y + c z \bm{ax+by+cz} ax+by+cz 的形式,然后分离 x , y , z \bm{x,y,z} x,y,z 的系数到矩阵中,这样我们就得到了变换矩阵。
图形学为什么都用矩阵表示变换呢?还记得我们在第三章说过,现代 GPU 有上万个并行单元可以参与计算,所以 GPU 可以高效并行计算上百万个结构化数据吗?用矩阵表示变换,其实是将变换数据进行"结构化"的过程,这样能更加方便 GPU 对数据的运输,分割和并行计算。所以矩阵才在图形学广泛运用,归根结底是因为方便 GPU 计算。
如果我们想对一个 3D 模型一次性做连续变换,该怎么办呢?例如下面的雅克 9P 战斗机,在平移的过程中还进行了旋转。那么呈现到画面的时候,我们对战斗机模型每个顶点 (假设模型有 30000 个顶点),每次都是先乘一个平移矩阵,再分别乘一个旋转矩阵吗?
不!这样做实在是太慢了!每帧这样我们就要进行 30000 × 2 = 60000 \bm{30000 \times 2 = 60000} 30000×2=60000 次乘法计算,这期间肯定要消耗帧率,如果碰到上百万顶点的大模型,游戏一定会卡到爆。
怎么进行优化呢?数学家发现与多个变换矩阵分别相乘,可以等价于乘由它们组合而成的一个复合变换矩阵:
这样的话,我们在变换之前,先把平移和旋转矩阵相乘 (注意相乘有前后顺序,矩阵相乘不满足交换律!) ,然后再把得到的新矩阵与顶点相乘,这样总共要乘 30000 + 1 = 30001 \bm{30000 + 1 = 30001} 30000+1=30001 次,几乎节省了一半的计算量!所以我们进行变换的时候,一般都是先得到复合变换矩阵再乘的。
一定要牢记!矩阵乘法不满足交换律!不满足交换律!不满足交换律!重要的事情说三次!希望大家要牢记!
矩阵乘法不满足交换律的示例图
上图是 "先平移后旋转",下图是 "先旋转后平移",可以看到两者得到的结果是完全不同的
Rotate 旋转,Scale 缩放,Shear 错切,Mirror 镜像,Projection 投影这五种变换都有一个共同的特点:它们的矩阵表示只有加减与数乘, x , y , z \bm{x,y,z} x,y,z 的项数均为 1,经它们变换后的空间基本不会扭曲变形。这些特殊的变换都属于线性变换。
线性变换需要满足以下两个条件:
-
变换后空间直线仍然保持等距且平行。
-
变换不能移动空间原点。
用数学公式表达就是:
那么,什么又是非线性变换呢?
- 变换后原来的直线被弯曲了,不能保持等距平行。
- 变换后移动了空间原点。(如果只是将原点移动,直线仍然保持等距平行,就会是后面会讲的仿射变换)
大家现在可能只理解到线性变换 不能移动原点,变换后空间直线保持等距平行 这两条性质,但线性变换本身究竟是什么呢?
空间中的向量都可以由基向量线性组合而成,那么线性变换是否会改变基向量的性质?
相互垂直的基向量称为正交基,是基向量的特例,我们常用的坐标系就是一组正交基。
看到上面的图吗?线性变换也会同时变化向量 v ⃗ \bm{\vec v} v 和基向量,然而这些新的基向量仍然能用相同的组合关系表示变换后的 v ⃗ \bm{\vec v} v,新的空间仍然能用 1 × i ⃗ + 2 × j ⃗ \bm{ 1 \times \vec i \space + 2 \times \vec j \space } 1×i +2×j 来表示 v ⃗ \bm{\vec v} v,这说明线性变换并没有改变基向量线性无关,线性组合的性质,线性变换实质是线性空间的转移。
因为线性变换的矩阵表示,只有加减和数乘,在图形学中,这样极其有利于 GPU 快速并行计算,优化运算代码,提高运算效率。
拓展阅读:Shader 中为什么尽量不要写除法和逻辑判断
缩放变换
缩放变换其实就是将物体朝一个方向放大、缩小、拉伸变形:
旋转变换
在讲三维空间绕任意轴旋转之前,我们先讲二维空间绕原点旋转的矩阵推导。
如图,已知向量 v ⃗ = ( 2 , 1 ) \bm{\vec v = (2,1)} v=(2,1),逆时针旋转 120° 得到新的向量 v ⃗ ′ \bm{\vec v'} v′ (右手规则规定逆时针为正),新向量坐标是多少呢?
看到这个问题,你一开始可能摸不着头脑,但没有关系,我们从最简单的直角三角形开始!
根据勾股定理,两条直角边的平方和,等于斜边的平方 ( a 2 + b 2 = c 2 ) \bm{(a^2 + b^2=c^2)} (a2+b2=c2):
我们把直角三角形放在坐标系里了,有没有发现一些很特殊的地方?
两条直角边恰好分别对应向量的 x \bm{x} x 和 y \bm{y} y 坐标,斜边长度恰好是向量的模 ∣ v ⃗ ∣ \bm{|\vec v|} ∣v∣。
我们让这个向量的模等于 1,让它变成一个单位向量,这样就有 x 2 + y 2 = ∣ v ⃗ ∣ 2 = 1 \bm{x^2 + y^2 = {|\vec v|}^2} = 1 x2+y2=∣v∣2=1 了。等等,这个方程不是一个圆吗?我们把这个向量放进圆里:
现在,我们把向量 v ⃗ \bm{\vec v} v 与 x \bm{x} x 轴的夹角记为 θ \bm{\theta} θ,然后用直角三角形的性质,求正弦值 s i n θ \bm{sin \theta} sinθ,余弦值 c o s θ \bm{cos \theta} cosθ:
大家有没有发现,历史总是惊人的相似!我们用单位向量的 x , y \bm{x,y} x,y 坐标去表示夹角正弦值、余弦值时,发现恰好有 x = c o s θ , y = s i n θ \bm{x=cos\theta,y=sin\theta} x=cosθ,y=sinθ 的关系!它们两个都有 x 2 + y 2 = 1 , c o s 2 θ + s i n 2 θ = 1 \bm{x^2+y^2=1,cos^2 \theta + sin^2 \theta = 1} x2+y2=1,cos2θ+sin2θ=1 的等式关系!因此,我们完全可以用 s i n θ , c o s θ \bm{sin \theta, cos \theta} sinθ,cosθ + 模的长度 来描述一个二维向量!这也是下面我们用矩阵表示旋转的基础!
接下来,我们用任意角度 α \bm{\alpha} α 旋转 v ⃗ \bm{\vec v} v,得到一个新向量 u ⃗ \bm{\vec u} u:
根据上面的规律,我们同样可以写出 u ⃗ \bm{\vec u} u 的表达式:
利用我们初中学过的两角和差公式,将三角式展开,对 s i n θ , c o s θ \bm{sin\theta , cos\theta} sinθ,cosθ 这些等价代换成 v ⃗ \bm{\vec v} v 的 x , y \bm{x,y} x,y,最后分离系数就可以得到二维空间下的变换矩阵了:
然而 3D 的旋转却事与愿违,很多时候我们旋转 3D 向量是没有一个确定的二维平面坐标系的,我们需要用已有的向量建立一个平面坐标系。怎么建立呢?
如图,这是 3D 空间下的某个平面,平面内已知向量 v ⃗ \bm{\vec v} v 逆时针旋转 α \bm{\alpha} α ° 得到向量 u ⃗ \bm{\vec u} u,现在没有了平面坐标系,该怎么办呢?
逢山开道,遇水造桥,没有坐标系,我们可以作垂直于 v ⃗ \bm{\vec v} v 且模长相等的的向量 w ⃗ \bm{\vec w} w ( v ⃗ ⊥ w ⃗ ) \bm{(\vec v \perp \vec w)} (v⊥w) ,用这两个向量 v ⃗ , w ⃗ \bm{\vec v,\vec w} v,w 来表示平面坐标系,把 v ⃗ \bm{\vec v} v 当作 x x x 轴,把 w ⃗ \bm{\vec w} w 当作 y y y 轴。
这样,我们就能用 ( c o s α , s i n α ) \bm{(cos \alpha, sin\alpha)} (cosα,sinα) 和两个向量 v ⃗ , w ⃗ \bm{\vec v,\vec w} v,w 的数乘与加法,来表示这个旋转后的向量 u ⃗ \bm{\vec u} u 了:
有了上面的基础,我们就可以正式推导三维任意轴旋转矩阵了:
在三维空间中,已知向量 v ⃗ \bm{\vec v} v 绕旋转轴 n ⃗ \bm{\vec n} n (旋转轴也是一个单位向量) 旋转 θ \bm{\theta} θ° 得到一个新向量 v ⃗ ′ \bm{\vec v'} v′,求这个新向量 v ⃗ ′ \bm{\vec v'} v′ 。
绕轴旋转本质还是某个平面内的二维旋转,利用下面的圆盘模型,我们可以把三维的旋转降维,这样就简单多了!(其中 P r o j n ⃗ v ⃗ \bm{Proj_{\vec n} \, \vec v} Projnv 是 v ⃗ \bm{\vec v} v 到 n ⃗ \bm{\vec n} n 的投影)
但是 v ⃗ ′ − P r o j n ⃗ v ⃗ \bm{\vec v' - Proj_{\vec n} \, \vec v} v′−Projnv 怎么求呢?我们可以做与 v ⃗ − P r o j n ⃗ v ⃗ \bm{\vec v - Proj_{\vec n} \, \vec v} v−Projnv 垂直的向量,借助上面的结论就行了!怎么做?可以用叉乘。做 v ⃗ − P r o j n ⃗ v ⃗ \bm{\vec v - Proj_{\vec n} \, \vec v} v−Projnv 与 n ⃗ \bm{\vec n} n 的叉乘,得到向量 w ⃗ \bm{\vec w} w,然后用这个向量 w ⃗ \bm{\vec w} w 和 v ⃗ − P r o j n ⃗ v ⃗ \bm{\vec v - Proj_{\vec n} \, \vec v} v−Projnv 就可以表示 v ⃗ ′ − P r o j n ⃗ v ⃗ \bm{\vec v' - Proj_{\vec n} \, \vec v} v′−Projnv 了:
注意! n ⃗ \bm{\vec n} n 一定要是单位向量!因为我们得出 v ⃗ ′ − P r o j n ⃗ v ⃗ = c o s θ ⋅ ( v ⃗ − P r o j n ⃗ v ⃗ ) + s i n θ ⋅ ( w ⃗ ) \bm{\vec v' - Proj_{\vec n} \, \vec v = cos\theta \cdot (\vec v - Proj_{\vec n} \, \vec v) + sin\theta \cdot (\vec w)} v′−Projnv=cosθ⋅(v−Projnv)+sinθ⋅(w) 这个结论,是建立在 w ⃗ \bm{\vec w} w 和 v ⃗ − P r o j n ⃗ v ⃗ \bm{\vec v - Proj_{\vec n} \, \vec v} v−Projnv 这两个向量的模长相等的基础之上的!如果 n ⃗ \bm{\vec n} n 不是单位向量,那么叉乘的时候就会影响到 w ⃗ \bm{\vec w} w 的模长,进而出现两向量模长不等的情况。
我们把上面 v ⃗ ′ \bm{\vec v'} v′ 的表达式一步步展开,分离系数,就能得到下面的矩阵了:
有时候我们是直接绕 x , y , z \bm{x,y,z} x,y,z 轴旋转的,我们可以得到下面的特殊矩阵:
仿射变换与平移
三维空间内平移后的点坐标是 ( x + T x , y + T y , z + T z ) \bm{(x+T_x, y+T_y, z+T_z)} (x+Tx,y+Ty,z+Tz) ,但是有没有想过, T x , T y , T z \bm{T_x,T_y,T_z} Tx,Ty,Tz 这些平移距离并不是 x , y , z \bm{x,y,z} x,y,z 这些参数的系数,多了一个维度,它们并不能简单地通过分离系数来表示,那如何用矩阵表示呢?
我能不能把 x + T x \bm{x+T_x} x+Tx 变形成 x ⋅ ( 1 + T x x ) \bm{x \cdot (1+ \frac{T_x}{x})} x⋅(1+xTx), y , z \bm{y,z} y,z 也这样变换,然后再用矩阵表示呢?
虽然我们把它变成了这么一个矩阵,但是计算机会不会用它呢?你一定会想着:不会!可是为什么呢?
因为平移是 ( x + T x , y + T y , z + T z ) \bm{(x+T_x, y+T_y, z+T_z)} (x+Tx,y+Ty,z+Tz) ,只需要做一次加法就行了。但是这个矩阵为了表示这个多出来的维度,还要额外做一次除法 ( T x x , T y y , T z z \bm{\frac{T_x}{x},\frac{T_y}{y},\frac{T_z}{z}} xTx,yTy,zTz)!而且是对参数 ( x , y , z \bm{x,y,z} x,y,z) 除!这对 GPU 来说是致命的,因为对 GPU 来说很难优化!GPU 为了包这盘饺子还要额外动用除法器 (除法器的性能一般都不如乘法器),做一次耗时的除法操作,这是我们不期望看到的!
而且上文我们提到过线性变换不能移动原点,原因是:原点移动后,变换后的基向量就不能再通过原来的组合关系来表示变换后的新向量了!所以平移不是线性变换。
虽然顶点不是线性变换,但它至少保证了变换后空间直线等距平行,我们把这种变换叫仿射变换。怎么用矩阵来表示仿射变换呢?我们只需要在原来 ( x , y , z ) \bm{(x,y,z)} (x,y,z) 的基础上,加一个参数 w \bm{w} w,让它变成 ( x , y , z , w ) \bm{(x,y,z,w)} (x,y,z,w),这样就能表示多出来的维度了。
等等,这不是齐次坐标吗?是不是有种豁然开朗的感觉?齐次坐标的另一个作用就是为了简化矩阵运算而生的,有了齐次坐标,我们就表示图形学里的各种变换。这样我们遇到需要多重变换的情况,尤其是里面有个平移变换的时候,我们都可以用只有加法与数乘运算的 4x4 矩阵来表示,不用抓耳挠腮,直接乘就行了。
再探顶点着色器与光栅化
顶点着色器:MVP 矩阵
在第三章,我们只是简单地提了一嘴 Vertex Shader 顶点着色器和 Rasterzation 光栅化的过程,简单地提到了矩阵与它们的紧密关系。但是矩阵在这两个阶段,究竟发生了什么作用?顶点组成的图形又是如何一步步变换成我们看到的屏幕像素?不用着急,我们接下来认识顶点着色器的核心:MVP 矩阵。
MVP 矩阵,其实是由 Model Matrix 模型矩阵,View Matrix 观察矩阵,Projection Matrix 投影矩阵 这三个矩阵依次相乘得到的复合矩阵。正是这个复合矩阵,我们才能将顶点从 Model Space 模型空间变换到 Homogeneous Clip Space 齐次裁剪空间,提供光栅化所需要的顶点数据。
坐标系矩阵的推导
在此之前,我们先想想,坐标系之间是怎么进行转换呢?
左图的坐标系 A 我们已知向量 p ⃗ = ( x , y ) \bm{\vec p = (x,y)} p=(x,y),右图坐标系 B 中 p ⃗ = ( x ′ , y ′ ) \bm{\vec p = (x',y')} p=(x′,y′),如何求这个 ( x ′ , y ′ ) \bm{(x',y')} (x′,y′) 呢?
我们可以用基向量来求,通过基向量转化坐标。左图坐标系 A 基向量是 u ⃗ , v ⃗ \bm{\vec u, \vec v} u,v,右图坐标系 B 基向量是 i ⃗ , j ⃗ \bm{\vec i, \vec j} i,j,它们的模长均为 1,我们只需要一步步转化,将 p ⃗ \bm{\vec p} p 用 i ⃗ , j ⃗ \bm{\vec i, \vec j} i,j 表示出来就行了:
然后就可以轻松地用矩阵表示了:
然而坐标系还会包含原点位置信息,例如下图中坐标系 A 和坐标系 B 的原点位置并不同,我们还需要对坐标系进行平移:
问题来了,上文提到,平移会多出一个维度的信息,只有 x , y , z \bm{x,y,z} x,y,z 是完全表示不出的,我们还需要加一个齐次分量 w \bm{w} w,让它变成齐次坐标,这样用矩阵表示就非常方便了。
下图是二维任意坐标系 A 转换到坐标系 B 的矩阵推导,其中 ( Q x , Q y ) \bm{(Qx, Qy)} (Qx,Qy) 是坐标系 A 原点在坐标系 B 的坐标:
以此类推,我们也可以得出 3D 齐次坐标系下的坐标系转换矩阵:
看到这你可能会有些奇怪,为什么矩阵最后一个元素是 1 呢?还记得光栅化有一步叫透视除法吗?我们需要保留 w \bm{w} w 分量的原始信息,为后文的透视除法统一到 NDC 空间做准备。
Model Matrix 模型矩阵
很多时候,我们在 3D 场景中用到的坐标系不是绝对的。每个 3D 模型都会拥有一个相对独立的模型坐标系,模型坐标系一般都会建在模型中心,这样方便建模。
后面我们还会遇到更复杂的人体模型,甚至每个骨骼都有自己独立的坐标系,非常恐怖,但有了独立坐标系就很方便确定皮肤网格了 (皮肤需要依附在骨骼上,构成皮肤的顶点受骨骼影响,相对的坐标系方便快速建立皮肤网格,还可以快速确定要影响的节点和网格,方便做骨骼动画)。
但是只有相对的模型坐标系不行,很多模型的坐标系都不尽相同。我们需要把这些模型放进世界空间中,统一到世界坐标系中。
View Space 模型空间 → \bm{\rightarrow} → World Space 世界空间,这个操作怎么实现呢?答案是乘矩阵,这个矩阵就叫模型矩阵。乘模型矩阵的目的是让模型坐标系与世界坐标系重合,后续的变换原理都是乘一个矩阵,使坐标系重合,这样空间内的顶点也会跟着变换,得到我们想要的结果。
我们对模型上的每个顶点乘它的模型矩阵,就可以得到顶点在世界坐标系下的实际坐标:
矩阵乘法不满足交换律!这一点请牢记!
View Matrix 观察矩阵
第二步就是将顶点从 World Space 世界空间变换到 View Space 观察空间了,执行这一操作的矩阵叫观察矩阵。
但是,我们只有摄像机原点在世界坐标系的位置 Q \bm{Q} Q、摄像机的焦点位置 T \bm{T} T,想要构建以摄像机原点为中心的观察坐标系,这两条信息完全不够 (只能构建一条向量,而构建三维坐标系需要两个不共线的已知向量),该怎么办呢?别急,我们可以引入额外的向量:世界坐标系中方向垂直向上的单位向量 j ⃗ \bm{\vec j} j。这样我们就有充足的信息了。
如图,根据左手坐标系, T − Q \bm{T-Q} T−Q 得到第一个观察向量 w ⃗ \bm{\vec w} w,然后我们将 w ⃗ \bm{\vec w} w 除以它的模 ∣ w ⃗ ∣ \bm{|\vec w|} ∣w∣ ,让它变成一个单位向量 (这点非常重要!),然后对单位向量化的 w ⃗ \bm{\vec w} w 和世界坐标系向上的向量 j ⃗ \bm{\vec j} j 进行叉乘,再将叉乘结果变成单位向量,得到向右的单位向量 u ⃗ \bm{\vec u} u,这是第二个向量;最后,我们对 u ⃗ \bm{\vec u} u 和 w ⃗ \bm{\vec w} w 叉乘,就能得到第三个相对观察空间向上的向量 v ⃗ \bm{\vec v} v 了:
这里为什么要进行两次向量单位化呢?因为向量模长和向量之间的夹角都会影响叉乘结果,不是单位向量会导致叉乘结果或长或短,影响坐标系,导致整个空间的顶点都会伸长或缩短,得出我们不想要的结果。
这样我们就得到了观察空间的坐标轴 x y z \bm{xyz} xyz ( x \bm{x} x 轴对应向右的向量 u ⃗ \bm{\vec u} u, y \bm{y} y 轴对应向上的向量 v ⃗ \bm{\vec v} v, z \bm{z} z 轴对应向后的向量 w ⃗ \bm{\vec w} w),接下来,我们怎么构建这个观察矩阵呢?能不能利用上文的坐标系转换矩阵来构建呢?
当然能,上面我们已经推出了 u ⃗ , v ⃗ , w ⃗ , Q \bm{\vec u, \vec v, \vec w, Q} u,v,w,Q 到底是怎么来的,把数据一个个填进去就行了。但是我们方向搞反了,这个矩阵是 观察空间 → \bm{\rightarrow} → 世界空间 的,我们想要的是 世界空间 → \bm{\rightarrow} → 观察空间 这样的逆变换,怎么办呢?
程序员们发现,逆变换可以通过原矩阵的逆矩阵表示:
但是我们怎么求出上面坐标系变换的逆矩阵呢?类比连续变换相当于连乘多个变换矩阵,多个变换矩阵连乘相当于乘一个复合变换矩阵的思想,上面的坐标系变换矩阵,我们可以拆解成 一个平移矩阵 M T r a n s l a t e \bm{M_{Translate}} MTranslate 和 一个旋转矩阵 M R o t a t e \bm{M_{Rotate}} MRotate 的乘积:
怎么样,神奇吗?这个复合矩阵其实只是将坐标系先平移再旋转了一下,所以我们可以根据它的特点,把它拆成平移矩阵 × \bm{\times} × 旋转矩阵的样子,数学家总结了这两个矩阵求逆的特殊性质,我们就可以根据这些性质快速求逆,从而得到 View Matrix 观察矩阵:
Projection Matrix 投影矩阵
本文最难的一节!请大家打起精神,拿上装备,准备迎接 BOSS 战!
第三章的时候我们提到,图形学为了模拟人眼的可视范围,引入了视锥体这个透视模型。只有模型矩阵 M m o d e l \bm{M_{model}} Mmodel 和观察矩阵 M v i e w \bm{M_{view}} Mview 还远远不够,因为物体乘完这两个矩阵之后,还是非常大,直接映射到屏幕还会丢失透视关系,导致看起来非常怪:
视椎体是一个棱台,但是屏幕只能表示棱台的一部分,怎么办?类比电影院放电影,我们可以利用透视投影变换 (下面会讲这个变换),用一个投影平面表示我们看见的场景,利用投影变换,将视椎体的东西映射到投影平面上就行了:
我们先来推导视椎体的点如何进行透视投影。我们的眼睛有视野范围,是有视野角度的,视椎体同样也有视野角度,向上下看的角度叫 Vertical field of view angle 垂直视场角 (图中 α \bm{\alpha} α 角),向左右看的角度叫 Horizontal field of view angle 水平视场角 (图中 β \bm{\beta} β 角),我们能看到的最近距离平面叫 Near Plane 近平面,最远距离的平面叫 Far Plane 远平面:
我们推导投影变换,只需要用上四个量:垂直视场角 α \bm{\alpha} α ,投影平面宽和高的比值 r = w h \bm{r = \frac{w}{h}} r=hw,近平面到原点的距离 n \bm{n} n,远平面到原点的距离 f \bm{f} f。也许你可能好奇,为什么用到的是投影平面宽高的比值,而不是具体的宽度和高度呢?因为投影大小只和视椎体有关,投影平面具体的宽度和高度对投影变换没啥用,都会通过一系列数学运算转化成比值,这个比值才是我们要用的,投影平面的位置并不确定,这就是为什么上图我们见不到投影平面的原因。(另外也有资料说 DirectX 的投影平面高度就是 2,所以下文投影窗口的高度设为 h = 2 \bm{h = 2} h=2,不仅仅是为了运算方便,还有图形 API 的原因)。
在视椎体内一点 P = ( x , y , z , 1 ) \bm{P=(x,y,z,1)} P=(x,y,z,1),如何得到投影到任意平面的点 P ′ = ( x ′ , y ′ , z ′ , 1 ) \bm{P'=(x',y',z',1)} P′=(x′,y′,z′,1) 呢?如图:
我们设投影平面距离原点的 z \bm{z} z 轴距离为 d \bm{d} d,要注意我们现在是从观察空间变换到齐次裁剪空间!所以我们将投影平面的高设置为 h = 2 \bm{h=2} h=2 (首先保证坐标系 y \bm{y} y 轴向量是单位向量,这样好处理),宽高比值是 r = w h = w 2 \bm{r = \frac{w}{h} = \frac{w}{2}} r=hw=2w,所以这里投影平面宽度是 2 r \bm{2r} 2r。大家看到没有,我们很容易发现:
神奇吗?这说明:投影平面 (投影窗口) 距离原点的 z \bm{z} z 轴距离 d \bm{d} d 、水平视场角 β \bm{\beta} β 完全可以通过已知条件 垂直视场角 α \bm{\alpha} α、投影平面宽高比 r \bm{r} r 表示。再来看这副图:
观察这幅图,我们可以利用初中学的相似三角形,再进行相应的等量代换,求出投影点 P ′ \bm{P'} P′ 的 x ′ , y ′ \bm{x',y'} x′,y′ 坐标:
上文我们推导观察矩阵的时候,我们为了保证图像不会突然拉太远或拉太近,将坐标系向量进行了两次单位化。现在投影这边,我们已经将 y \bm{y} y 轴单位化了 ( − 1 < = y ′ < = 1 ) \bm{(-1 <= y' <= 1)} (−1<=y′<=1), x \bm{x} x 轴这边也要单位化,可是 x \bm{x} x 轴的范围是 ( − r < = x ′ < = r ) \bm{(-r <= x' <= r)} (−r<=x′<=r),有宽高比 r \bm{r} r 的,怎么办?
答案很简单:让 x ′ \bm{x'} x′ 也除以 r \bm{r} r,这样投影的 x \bm{x} x 轴也是 [ − 1 , 1 ] \bm{[-1,1]} [−1,1] 范围了:
经过上面的单位化操作,我们最终得到投影点 P ′ \bm{P'} P′ 的 x ′ , y ′ \bm{x',y'} x′,y′ 坐标:
x , y \bm{x,y} x,y 轴向量也都单位化了,现在我们让 z \bm{z} z 轴也要单位化一下。
等等,我们都把所有顶点都映射到投影平面 (投影窗口) 上了:
苇草是你的妹!投影点深度都是 d \bm{d} d ,这该怎么办?更何况后面还会有复杂的遮挡关系,丢失了原来的深度,那该怎么渲染呢?
有没有一种方法,既能完成投影操作,又能保存投影点的深度信息呢?
我们可以弄一个单调递增的函数 g ( z ) = A x + B \bm{g(z) = Ax+B} g(z)=Ax+B,这个函数的作用是将原来的深度信息,映射到一个更小的范围,并且仍然能保证深度的顺序:
如图,我们将近平面映射为 -1 ( g ( n ) = A n + B = − 1 ) \bm{(g(n)=An+B=-1)} (g(n)=An+B=−1),将远平面映射为 1 ( g ( f ) = A f + B = 1 ) \bm{(g(f)=Af+B=1)} (g(f)=Af+B=1) ,解个方程组就可以得到 A , B \bm{A,B} A,B 的值,我们就可以得到这个映射函数的解析式了。诶诶,先别着急解,我还没讲完:
上面我们求得 P ′ \bm{P'} P′ 的坐标 x ′ , y ′ \bm{x',y'} x′,y′ 里面都有 1 z \bm{\frac{1}{z}} z1,我们的 4 × 4 \bm{4 \times 4} 4×4 矩阵没有 1 z \bm{\frac{1}{z}} z1 这个维度 (只有 x , y , z \bm{x,y,z} x,y,z 和一个用于表示平移的常数维),如果硬是要表示它,那么矩阵一定会变成和上面平移矩阵一样的非线性矩阵,额外增加 GPU 运算的负担,这是我们不期望看到的。
解决方案是,我们不用再额外给矩阵增加一个维度,相反,我们将 1 z \bm{\frac{1}{z}} z1 这个除以 z \bm{z} z 的任务交给光栅化阶段的透视除法。投影矩阵利用齐次坐标 w \bm{w} w 来保存原来的深度信息 z \bm{z} z,这样,投影点的 z ′ \bm{z'} z′ 就可以表示成 z ′ = A z + B \bm{z' = Az + B} z′=Az+B 了:
在没进行透视除法进入 NDC 空间之前,经过透视投影变换的图形将会在齐次裁剪空间等待裁剪,没错,这就是齐次裁剪空间的由来。MVP 矩阵的 Projection Matrix 投影矩阵其实也不是真正的"投影",只是将视椎体压缩到一个长宽高均为 w \bm{w} w 的正方体空间 (这个正方体空间就是齐次裁剪空间),将原本在视锥体的东西映射到正方体里面,方便屏幕表示。
真正的透视投影发生在透视除法,透视除法除以齐次分量 w \bm{w} w, w \bm{w} w 越小, 1 w \bm{\frac{1}{w}} w1 越大,离投影平面近的 w < 1 \bm{w<1} w<1 ,透视除法之后会放大;离投影平面远的 w > 1 \bm{w>1} w>1 ,透视除法之后会缩小,这就是投影现象的产生。
有了上面的基础,我们就可以推导投影矩阵了:
现在我们来推导矩阵里面的 A , B \bm{A, B} A,B 究竟是什么。因为我们的最终目标是长宽高都为 1 的 NDC 空间,齐次裁剪空间 ⟶ \bm{\longrightarrow} ⟶ NDC 空间需要经历一次透视除法,所以我们需要修改映射方程 g ( z ) \bm{g(z)} g(z),从 − 1 ≤ A z + B ≤ 1 \bm{-1 \leq Az+B \leq 1} −1≤Az+B≤1 修改成 − 1 ≤ \bm{-1 \leq } −1≤ A z + B z \bm{\frac{Az+B}{z}} zAz+B ≤ 1 \bm{\leq 1} ≤1,这样我们才能正确求解 A , B \bm{A,B} A,B 的值:
上面这个求 g ( z ) \bm{g(z)} g(z) 的过程,就叫归一化深度值,作用是将视椎体内投影点的 z \bm{z} z 分量映射到 [ − 1 , 1 ] \bm{[-1,1]} [−1,1] 中,变换到 NDC 空间中。这个映射仍然是单调递增的,仍然能保证原来的深度顺序 (保序性),不过会有个特点:与近平面越近的点,会更加突出。
我们把 A , B \bm{A,B} A,B 代进矩阵里,就能得到最终的投影矩阵了:
看到这你可能会很疑惑,我们所有的操作,包括让 x \bm{x} x 除以投影平面宽高比 r \bm{r} r,构造函数 g ( z ) \bm{g(z)} g(z) 归一化深度值,用齐次分量 w \bm{w} w 保存原来的深度信息,归根结底都是为了变换到 NDC 空间做准备。
为什么我们一直要纠结这个 NDC 空间呢?
第一个原因: NDC 空间 x , y , z \bm{x,y,z} x,y,z 的范围均为 [ − 1 , 1 ] \bm{[-1,1]} [−1,1],很方便硬件在后面光栅化的时候进行屏幕映射,怎么个方便法呢?每个屏幕的分辨率不同,像素大小也不同,如果传递过来的图像不是 1:1 比例,图形 API 就要向硬件传递额外的宽高比参数 r \bm{r} r,还要传递额外的除法命令。硬件要先除以这个宽高比 r \bm{r} r,再进行屏幕映射,不先做这一步就会发生图形畸变,非常麻烦。我们在投影矩阵中提前处理这个宽高比 r \bm{r} r,规避 r \bm{r} r 的影响,让 x \bm{x} x 和 y \bm{y} y 一样映射到范围 [ − 1 , 1 ] \bm{[-1,1]} [−1,1],这样硬件就可以直接套预设的公式进行屏幕映射了,非常方便。
第二个原因:齐次裁剪空间虽然是正方体空间,但它是齐次空间,不是欧氏空间!况且齐次裁剪空间里的顶点 w \bm{w} w 分量不为 1,顶点还没进行透视除法,透视现象还没产生,我们不能直接对齐次裁剪空间进行屏幕映射,需要都除以 w \bm{w} w (没错, z \bm{z} z 也要跟着 x , y \bm{x,y} x,y 除 w \bm{w} w 映射到范围 [ − 1 , 1 ] \bm{[-1,1]} [−1,1], z \bm{z} z 轴不跟 x , y \bm{x,y} x,y 轴统一的话,就会导致坐标系是斜坐标系,图形会变相拉斜,变换后的深度信息也需要符合透视"近大远小"的规则,至于为什么要在分量 w \bm{w} w 保留原来的深度信息,是因为还要用于光栅化的裁剪阶段),让它们都变换到 NDC 这种标准欧氏空间,这样诸如屏幕映射这样的线性操作才能正常使用。
推导透视投影矩阵确实是本文最难的一节,连作者我都好几次被这些概念和问题难倒,不得不广思集益,打破砂锅问到底。不过看到这里,你已经很厉害了,加油!
光栅化:裁剪与背面剔除、透视除法与屏幕映射
|
---|
在第三章和第四章,我们只是提了一嘴光栅化的过程,这节教程,我们将彻底弄懂:光栅化到底是如何将顶点变成像素点阵呢?
裁剪与背面剔除
裁剪
上文经过 MVP 变换后,我们将顶点都变换到了齐次裁剪空间内。上文说过:用 w \bm{w} w 分量保存原始深度信息就是为了方便裁剪的。
裁剪立方体外的顶点会映射到屏幕外面,所以它们不可见,无需参与后续的流程。所以裁剪阶段的任务是保留立方体内的部分,剔除立方体外的部分,减少运算量,提高渲染效率。
裁剪算法由硬件实现,基础原理是由两位计算机科学家 Ivan Sutherland (这位是图形学奠基人,被称为“计算机图形学之父”) 和 Gary Hodgman 在1974年提出的 Sutherland-Hodgman 裁剪算法。读者有兴趣可以点击下方的三篇文章了解一下原理:
伊凡·苏泽兰特(Ivan Sutherland)——计算机图形学之父的简史
Sutherland–Hodgman 算法介绍(简单易懂) —— Curz酥的博客
图形学初识–多边形剪裁算法 —— 航火火
背面剔除
第三章我们提到,物体的背面我们看不到,在裁剪操作之后,会剔除物体的背面,这一操作就叫背面剔除。那么究竟是如何判断物体的正背面呢?顶点绕序又是怎么决定物体的正背面朝向呢?
答案:向量叉乘。
假设我们规定顺时针绕序为正面,逆时针绕序为背面,向量叉乘遵循右手定则。
顶点绕序可以决定向量的方向,如图,在齐次裁剪空间中 z \bm{z} z 轴朝里,现在伸出你的右手比一比!!!按照右手定则:
- 顺时针绕序下,两向量叉乘得到的新向量 z > 0 \bm{z>0} z>0,与 z \bm{z} z 轴方向相同 (指向屏幕内),所以这是正面:
- 逆时针绕序下,两向量叉乘得到的新向量 z < 0 \bm{z<0} z<0,与 z \bm{z} z 轴方向相反 (指向屏幕外),所以这是背面:
神奇吗?背面裁剪,就这么简单。
透视除法与 NDC 空间
上文在推导 Projection Matrix 的时候我们就知道,透视除法的作用是除以 w \bm{w} w 分量,将顶点从齐次裁剪空间变换到 NDC 空间,这个时候的顶点才会发生透视投影,符合透视"近大远小"的特征。变换到 NDC 空间后,顶点就可以准备进行屏幕映射了。
为什么裁剪应该在透视除法之前进行?
因为顶点的 w \bm{w} w 分量是可以为负的,这些顶点有可能不在视锥体内,恰好在视锥体前面 ( z < 0 \bm{z<0} z<0)。如果先进行透视除法再裁剪,这些顶点会除以 w \bm{w} w 变换到立方体里面 (因为顶点的 z < 0 \bm{z<0} z<0 , w < 0 \bm{w<0} w<0, z w > 0 \bm{\frac{z}{w}>0} wz>0,于是顶点就会变换到 z \bm{z} z 轴正半轴),视角背后的东西变换到前面来了,导致错误渲染 (无图不说话,有图有真相):
实际上真正的裁剪是在正方体空间 (齐次裁剪空间) 上进行的,不是在视椎体 (观察空间) 进行
这里为了方便演示,让大家看出顺序先后的区别,所以选择了在视椎体所在的观察空间进行裁剪
屏幕映射
最后一步,顶点经过屏幕映射之后,将会从 NDC 空间 变换到 屏幕空间,这个时候顶点才会处于屏幕坐标系,屏幕才可以表示顶点。
这个阶段同样由光栅化实现,做这件事的东西叫 Viewport 视口,所以 Screen Mapping 屏幕映射又叫 Viewport Transformation 视口变换。开发者只需要向图形 API 提供视口信息就行了。
(W,H 是视口宽高,N 为最小映射深度,一般为 0;F 为最大映射深度,一般为 1)
光栅化:三角形设置 (图元组装)
顶点映射到屏幕上,第一件要做的事就是连线组成图元,把相关的两个顶点“连连看”,有些能构成面,有些只是线,有些甚至没有与之配对的顶点只能当一个“单身狗”,这个就是三角形设置 (图元组装) 要完成的任务。
"连线"其实是找到顶点连线所成线段的屏幕像素表示,并进行插值。但是我们怎么进行"连线"呢?
接下来我们要介绍一个重量级算法:Bresenham 布雷森汉姆直线算法。
视频里的"增量法"就是 Bresenham 直线算法 |
---|
中点画线算法
第三章讲过,我们的屏幕是由很多个 1x1 的小格子组成的,这些小格子的中心都有一个像素点。像素点的坐标是整数。
如何确定像素点呢?如下图,假设直线的斜率在 [ 0 , 1 ] \bm{[0,1]} [0,1] 范围内,点 Q \bm{Q} Q 应该选择上面的像素点 P u \bm{P_u} Pu,还是下面的点 P d \bm{P_d} Pd 呢?
答案很明显,就是上面的 P u \bm{P_u} Pu 点。因为相比下面的 P d \bm{P_d} Pd 点,它离 P u \bm{P_u} Pu 点更近。
怎么算出来的呢?
如图,我们很容易观察到,图上每两个相邻点的高度是斜率 k \bm{k} k (证明: k = Δ y Δ x = y 2 − y 1 x 2 − x 1 \bm{k = \frac{\Delta y}{\Delta x} = \frac{y_2-y_1}{x_2-x_1}} k=ΔxΔy=x2−x1y2−y1,每个点恰好都落在网格线上,所以每个相邻点相差的 x \bm{x} x 轴长度是 Δ x = x 2 − x 1 = 1 \bm{\Delta x = x_2 - x_1 = 1} Δx=x2−x1=1,所以 Δ y = y 2 − y 1 = k \bm{\Delta y = y_2 - y_1 = k} Δy=y2−y1=k)。
我们沿 x \bm{x} x 轴每次移动 1 个长度,如何知道选择的是上面的点 P u \bm{P_u} Pu,还是下面的点 P d \bm{P_d} Pd 呢?
观察上图,我们发现:直线与网格的交点 P i \bm{P_{i}} Pi 与它下面的点 P d \bm{P_d} Pd 相减,可以得到误差 d ( d = P i − P d ) \bm{d \ (d = P_i - P_d)} d (d=Pi−Pd),很容易知道:
- 当 d > 0.5 \bm{d>0.5} d>0.5 时 (交点越过当前格子的中线),应选择上面的点 P u \bm{P_u} Pu
- 当 d ≤ 0.5 \bm{d \leq 0.5} d≤0.5 时 (交点低于,或恰好在当前格子的中线),应选择下面的点 P d \bm{P_d} Pd
这样的话,我们只要每次向 x \bm{x} x 轴移动,求出每个交点的 y \bm{y} y 坐标,再求出每个交点的误差 d \bm{d} d,就能判断应该选择哪个点了:
// 假设直线斜率范围在 (0,1) 之内,且 x2 > x1,y2 > y1
void Drawline(int x1, int y1, int x2, int y2)
{
float k = (y2 - y1) / (x2 - x1); // 斜率
float b = (x2 * y1 - x1 * y2) / (x2 - x1); // y 轴截距
int now_draw_x = x1; // 当前绘制的像素点 x 坐标
int now_draw_y = y1; // 当前绘制的像素点 y 坐标
float d = 0; // 每个交点的误差
DrawPixel(now_draw_x, now_draw_y, BLUE); // 先绘制当前像素点
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
float y = k * now_draw_x + b; // 交点的 y 轴坐标
now_draw_y = y; // 对 y 进行取整,得到下面点的坐标
d = y - now_draw_y; // 算出每个交点的误差 d
if(d > 0.5) now_draw_y++; // 误差大于 0.5,交点越过中线,就画上面的点
// 否则不自增,画下面的点
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
}
}
但是每算一次交点,都要做一次浮点数的乘法和加法运算 ( y = k x + b ) \bm{(y=kx+b)} (y=kx+b),这也太麻烦了!有没有更简单快速的方法呢?能不能避开求交点呢?
当然有方法,观察图片就会发现,每一个误差 d i \bm{d_i} di 的长度都是上一次误差 d i − 1 \bm{d_{i-1}} di−1 与斜率 k \bm{k} k 之和,而且长度一定小于 1,大于或等于 1 就会被截断 ( d i = ( d i − 1 + k ) % 1 ) \bm{(d_i = ( d_{i-1} + k ) \% 1)} (di=(di−1+k)%1),这个误差 d \bm{d} d 恰好总是交点与最近下方点的距离 ( d i = P i − P d ) \bm{(d_i = P_i - P_d)} (di=Pi−Pd)。
因此我们完全不用再这么麻烦地求交点的 y \bm{y} y 坐标,直接用 d \bm{d} d 就可以表示了:
// 假设直线斜率范围在 (0,1) 之内,且 x2 > x1,y2 > y1,中点画线算法
void Mid_Drawline(int x1, int y1, int x2, int y2)
{
float k = (y2 - y1) / (x2 - x1); // 斜率
int now_draw_x = x1; // 当前绘制的像素点 x 坐标
int now_draw_y = y1; // 当前绘制的像素点 y 坐标
float d = 0; // 从起始点开始的误差
DrawPixel(now_draw_x, now_draw_y, BLUE); // 先绘制当前像素点
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
d += k; // 每次 x 向右移动一个单位,误差 d 都要加上斜率 k
float e = d - 0.5; // 每次的误差与 0.5 (像素点中线) 相减得到 e
if(e>0) now_draw_y++; // 如果 e 朝上 (e>0),就画上面的点,否则不加,画下面的点
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
if(d>=1) d-=1; // 当误差大于 1 时,误差减 1,保持相对性
// 否则上面减 0.5 得到的 e 总会是正数
}
}
这个算法就叫中点画线算法:
这个算法还可以,可惜还是有浮点数运算 (开头的浮点数除法求斜率 k \bm{k} k 和循环中的浮点数加法),很多设备浮点数运算效率感人,浮点数运算速度比整数运算要慢得多,在涉及一些需要上万次画线的场景,这种方法可就捉襟见肘了。如何将浮点数运算都化成整数运算,这是后面优化算法的关键。
Bresenham 算法
别急,我们先来一步步优化:
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
d += k; // 每次 x 向右移动一个单位,误差 d 都要加上斜率 k
float e = d - 0.5; // 每次的误差与 0.5 (像素点中线) 相减得到 e
if(e>0) now_draw_y++; // 如果 e 朝上 (e>0),就画上面的点,否则不加,画下面的点
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
if(d>=1) d-=1; // 当误差大于 1 时,误差减 1,保持相对性
// 否则上面减 0.5 得到的 e 总会是正数
}
第一,看上面的循环,每次绘制的时候居然要判断两次,一次判断 d > 0.5 \bm{d > 0.5} d>0.5,一次判断 d ≥ 1 \bm{d \geq 1} d≥1!对于一些硬件来说是非常致命的,有没有什么方法,能将这两个判断合 2 为 1 呢?
有了!我们可以将 d \bm{d} d 的范围从 [ 0 , 1 ] \bm{[0,1]} [0,1] 映射到 [ − 0.5 , 0.5 ] \bm{[-0.5, 0.5]} [−0.5,0.5],映射后的结果仍然是相同的 (读者可以自己验证一下),这样 d ≥ 1 \bm{d \geq 1} d≥1 就可以折合进 d ≥ 0.5 \bm{d \geq 0.5} d≥0.5 了:
为什么可以这样做呢?因为实际上屏幕像素的中心坐标是像素左上角 +0.5 (像素左上角坐标是整数),后文的 SV_POSITION 在像素着色器中语义指向的坐标也是这个,我们可以利用这一特性,将 d \bm{d} d 的范围进行映射,这样就可以减少计算量了:
// 假设直线斜率范围在 (0,1) 之内,且 x2 > x1,y2 > y1
void Drawline(int x1, int y1, int x2, int y2)
{
float k = (y2 - y1) / (x2 - x1); // 斜率
int now_draw_x = x1; // 当前绘制的像素点 x 坐标
int now_draw_y = y1; // 当前绘制的像素点 y 坐标
float d = 0; // 从起始点开始的误差,可以是负数 (范围: [-0.5, 0.5])
DrawPixel(now_draw_x, now_draw_y, BLUE); // 先绘制当前像素点
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
d += k; // 每次 x 向右移动一个单位,误差 d 都要加上斜率 k
if(d>=0.5) // 判断 d 是否超过 0.5,超过就截断,同时绘制上面的像素点
{
d -= 1; // 减 1 进行截断,防止超出 [-0.5, 0.5] 的范围
now_draw_y++; // 绘制上面的点
}
// 如果没有进 if,就会绘制和上一次一样高的下面的像素点
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
}
}
我们简化一下 if,让它最多走 3 步就可以了:
// 假设直线斜率范围在 (0,1) 之内,且 x2 > x1,y2 > y1
void Drawline(int x1, int y1, int x2, int y2)
{
float k = (y2 - y1) / (x2 - x1); // 斜率
int now_draw_x = x1; // 当前绘制的像素点 x 坐标
int now_draw_y = y1; // 当前绘制的像素点 y 坐标
float d = 0; // 从起始点开始的误差,可以是负数 (范围: [-0.5, 0.5])
DrawPixel(now_draw_x, now_draw_y, BLUE); // 先绘制当前像素点
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
if(d>=0.5) // 判断 d 是否超过 0.5,超过就截断,同时绘制上面的像素点
{
d += k - 1; // 减 1 进行截断,防止超出 [-0.5, 0.5] 的范围
now_draw_y++; // 绘制上面的点
}
else // 否则,就绘制和上一次一样高的下面的像素点
{
d += k; // 这里仍要加 k
}
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
}
}
第二,我们要将浮点数运算都化成整数运算,第一件事就是拿斜率 k \bm{k} k 开刀,把 k \bm{k} k 的分母 x 2 − x 1 \bm{x_2 - x_1} x2−x1 抽出来,误差 d \bm{d} d 乘上这个 x 2 − x 1 \bm{x_2 - x_1} x2−x1:
// 假设直线斜率范围在 (0,1) 之内,且 x2 > x1,y2 > y1
void Drawline(int x1, int y1, int x2, int y2)
{
// float k = (y2 - y1) / (x2 - x1);
int now_draw_x = x1; // 当前绘制的像素点 x 坐标
int now_draw_y = y1; // 当前绘制的像素点 y 坐标
// d -> d * (x2 - x1)
float d = 0; // 从起始点开始的误差,可以是负数
DrawPixel(now_draw_x, now_draw_y, BLUE); // 先绘制当前像素点
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
// if(d >= 0.5) 判断 d 是否超过 0.5,超过就截断,同时绘制上面的像素点
// d >= 0.5 -> d * (x2 - x1) >= 0.5 * (x2 - x1)
if(d >= 0.5 * (x2 - x1))
{
// d += k - 1 -> d * (x2 - x1) += k * (x2 - x1) - 1 * (x2 - x1);
d += (y2 - y1) - (x2 - x1);
now_draw_y++; // 绘制上面的点
}
else // 否则,就绘制和上一次一样高的下面的像素点
{
// d += k; -> d * (x2 - x1) += k * (x2 - x1);
d += (y2 - y1);
}
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
}
}
接下来,我们把 d > = 0.5 ⟶ d ∗ ( x 2 − x 1 ) > = 0.5 ∗ ( x 2 − x 1 ) \bm{d >= 0.5 \, \longrightarrow \, d * (x_2 - x_1) >= 0.5 * (x_2 - x_1)} d>=0.5⟶d∗(x2−x1)>=0.5∗(x2−x1) 乘上 2 就可以将 d \bm{d} d 变换成整数类型了:
// 假设直线斜率范围在 (0,1) 之内,且 x2 > x1,y2 > y1
void Drawline(int x1, int y1, int x2, int y2)
{
// float k = (y2 - y1) / (x2 - x1);
int now_draw_x = x1; // 当前绘制的像素点 x 坐标
int now_draw_y = y1; // 当前绘制的像素点 y 坐标
// d -> d*2*(x2 - x1)
int d = 0; // 从起始点开始的误差,可以是负数
DrawPixel(now_draw_x, now_draw_y, BLUE); // 先绘制当前像素点
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
// if(d >= 0.5) 判断 d 是否超过 0.5,超过就截断,同时绘制上面的像素点
// d >= 0.5 -> d*2*(x2 - x1) >= 0.5*2*(x2 - x1)
if(d >= (x2 - x1))
{
// d += k - 1 -> d*2*(x2 - x1) += k*2*(x2 - x1) - 1*2*(x2 - x1);
d += 2*(y2 - y1) - 2*(x2 - x1);
now_draw_y++; // 绘制上面的点
}
else // 否则,就绘制和上一次一样高的下面的像素点
{
// d += k; -> d*2*(x2 - x1) += k*2*(x2 - x1);
d += 2*(y2 - y1);
}
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
}
}
最后,我们将上面的代码进行优化,将 ( x 2 − x 1 ) \bm{(x_2 - x_1)} (x2−x1) 和 ( y 2 − y 1 ) \bm{(y_2 - y_1)} (y2−y1) 进行代换,将乘 2 转换为位运算:
// 假设直线斜率范围在 (0,1) 之内,且 x2 > x1,y2 > y1,Bresenham 算法
void Bresenham_Drawline(int x1, int y1, int x2, int y2)
{
int dx = x2 - x1; // 两点之间 x 坐标增量
int dy = y2 - y1; // 两点之间 y 坐标增量
int double_dx = dx << 1;// 2*dx,乘 2 相当于左移一位
int double_dy = dy << 1;// 2*dy,乘 2 相当于左移一位
int now_draw_x = x1; // 当前绘制的像素点 x 坐标
int now_draw_y = y1; // 当前绘制的像素点 y 坐标
int d = 0; // 从起始点开始的动态误差,可以是负数
DrawPixel(now_draw_x, now_draw_y, BLUE); // 先绘制当前像素点
while(now_draw_x < x2) // 从 x1 到 x2,每次循环向 x 轴移动一个单位
{
now_draw_x++; // x 向右移动一位,绘制下一个像素
if(d >= dx) // 如果大于或等于,就截断,并绘制上面的像素点
{
d += double_dy - double_dx;
now_draw_y++; // 绘制上面的点
}
else // 否则,就绘制和上一次一样高的下面的像素点
{
d += double_dy;
}
DrawPixel(now_draw_x, now_draw_y, BLUE); // 绘制像素
}
}
上面的代码就是著名的 Bresenham 算法。
据说 Bresenham 发明这个算法的出发点非常的有趣:当时他工作的地方没有能够进行浮点运算的机器,而他又要绘制很多的直线,因此他不得不面临一个严峻的问题:如何在不涉及浮点运算的条件下画直线?
经过一番探索,才有了如今的 Bresenham 算法。并且因为整数运算在硬件上会比浮点运算快很多,Bresenham 算法得到了很广泛的使用和推广。
Bresenham 算法巧妙运用了增量误差思想,也就是说通过对前一个点在 X 和 Y 轴方向上加上一个增量,从而得到一个新点坐标。这个算法要求先算出直线的斜率,然后从起点开始,确定最佳逼近于直线。它最为关键的一点是:只有整数加法与位运算,这也是它比其他直线光栅化算法更高效的原因。哪怕是计算机绘圆,椭圆,甚至工控领域,都有它的身影。
Bresenham 直线算法由硬件实现。上面的代码我们只是展示了直线八种情况的一种 (下图黑线部分),读者可以自行推导其他情况的代码:
|
---|
拓展阅读:
【译】Bresenham 直线算法
计算机图形学 学习笔记(一):概述,直线扫描转换算法:DDA,中点画线算法,Bresenham算法
光栅化:三角形遍历
三角形里面的像素点一开始是没有数据的,三角形遍历阶段又是如何进行填充的呢?
扫描算法
问题来了,如何找到三角形覆盖的全部像素?
答案:三角形扫描转换。
任何三角形都可以最多分为一个平顶和一个平底三角形,三角形扫描转换,就是从三角形最上面的点开始往下逐步画横线,两个交点之间的区域就是覆盖的区域。
三角形扫描转换大体思路是:
首先确定三角形 y \bm{y} y 轴方向的最大坐标和最小坐标,然后从上到下逐行扫描。
判断每行的起点和终点,方法很简单:
我们知道一条线段的起始点,就能写出线段的二元一次方程,将 y \bm{y} y 带入方程即可得到对应的 x \bm{x} x (也就是每行的起始点)。
观察三角形可以发现,任何一个三角形的一端边缘由一条线段构成,而另一端则由两条线段构成,因此算法需要分两部分处理,一部分处理上图 p 0 , p 1 \bm{p_0 \, , \, p_1} p0,p1 之间的扫描线,一部分处理 p 1 , p 2 \bm{p_1 \, , \, p_2} p1,p2 之间的扫描线。
三角形重心插值
扫描转换完成后,该如何对三角形覆盖的网格进行插值呢?
答案:三角形重心坐标插值。
重心坐标是啥呢?重心坐标系统最早由德国数学家奥古斯特·莫比乌斯于 19 世纪引入。他在研究三角形内部点的位置时,通过引入重心坐标,将一个点表示为三个顶点的权重和。例如,对于三角形 △ A B C \bm{\triangle ABC} △ABC 上的点 P \bm{P} P,它可以表示为 P = α A + β B + γ C \bm{P = \alpha A+ \beta B + \gamma C} P=αA+βB+γC,其中 α , β , γ \bm{\alpha , \beta , \gamma} α,β,γ 都是非负实数,并且满足 α + β + γ = 1 \bm{\alpha +\beta + \gamma = 1} α+β+γ=1。
如何理解 α , β , γ \bm{\alpha , \beta , \gamma} α,β,γ 这三个数呢?这三个数你可以理解为点 P \bm{P} P 与三点的连线,分割成了三个小三角形 (见上图),而 α , β , γ \bm{\alpha , \beta , \gamma} α,β,γ 分别代表这三个小三角形占整个三角形的面积比,具体原理及推导可见下面这篇文章:
进行重心坐标插值后,三角形内部覆盖的像素就会填充纹理 UV 坐标,法线坐标,深度信息,颜色等数据了。
拓展知识:Perspective-Correct Interpolation 透视校正插值
仅仅是重心插值就可以完成任务了吗?看下面的图,左数第一幅 “Flat” 是待插值的纹理,第二幅 “Affine” 是经过透视投影,插值完成后得到的结果。很明显这张图看起来很怪,完全不符合我们的预期 (图三 “Correct”):
为什么会这样呢?原因就是因为透视投影。例如下面的图,投影平面的中点并不对应着原线段的中点,如果直接进行插值的话,就会取 C \bm{C} C 点 (假中点) 的结果,而不是 C \bm{C} C 点下方 (真中点) 的结果:
透视投影并不是像太阳光那样,平行光照在物体上投影,相反,透视投影其实是各种方向不同的光,朝着一点 (摄像机原点) 投影到平面上。归根结底是透视投影发生了非线性变化,想要正确插值,就必须进行校正,这就是透视校正插值的由来。
透视校正插值由硬件完成,读者可以点击下面的链接了解一下:
【重心坐标插值、透视矫正插值】原理以及用法见解 (GAMES101 深度测试部分讨论)
透视校正插值 - Vulkan - 博客园
开始画方块吧
创建常量缓冲资源:CreateCBVResource
接下来,我们要介绍一个新的资源类型:Constant Buffer 常量缓冲:
常量缓冲是一块预先分配的小型高速显存,可以存储经常变换的数据 (例如我们这里的 MVP 矩阵,每帧都需要变化,所以要存储到常量缓冲中),常量缓冲的数据运输速度比其它缓冲要快的多,因此它是 CPU 向 GPU 传输数据的高速信使。
和默认堆资源一样,常量缓冲也需要从 CPU 端开始创建,由于它是显存资源,指定创建大小时仍然要进行 256 字节对齐:
ComPtr<ID3D12Resource> m_CBVResource; // 常量缓冲资源,用于存放 MVP 矩阵,MVP 矩阵每帧都要更新,所以需要存储在常量缓冲区中
struct CBuffer // 常量缓冲结构体
{
XMFLOAT4X4 MVPMatrix; // MVP 矩阵,用于将顶点数据从顶点空间变换到齐次裁剪空间
};
CBuffer* MVPBuffer = nullptr; // 常量缓冲结构体指针,里面存储的是 MVP 矩阵信息,下文 Map 后指针会指向 CBVResource 的地址
// 常量资源宽度,这里填整个结构体的大小。注意!硬件要求,常量缓冲需要 256 字节对齐!所以这里要进行 Ceil 向上取整,进行内存对齐!
UINT CBufferWidth = Ceil(sizeof(CBuffer), 256) * 256;
D3D12_RESOURCE_DESC CBVResourceDesc = {}; // 常量缓冲资源信息结构体
CBVResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; // 上传堆资源都是缓冲
CBVResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; // 上传堆资源都是按行存储数据的 (一维线性存储)
CBVResourceDesc.Width = CBufferWidth; // 常量缓冲区资源宽度 (要分配显存的总大小)
CBVResourceDesc.Height = 1; // 上传堆资源都是存储一维线性资源,所以高度必须为 1
CBVResourceDesc.Format = DXGI_FORMAT_UNKNOWN; // 上传堆资源的格式必须为 DXGI_FORMAT_UNKNOWN
CBVResourceDesc.DepthOrArraySize = 1; // 资源深度,这个是用于纹理数组和 3D 纹理的,上传堆资源必须为 1
CBVResourceDesc.MipLevels = 1; // Mipmap 等级,这个是用于纹理的,上传堆资源必须为 1
CBVResourceDesc.SampleDesc.Count = 1; // 资源采样次数,上传堆资源都是填 1
// 上传堆属性的结构体,上传堆位于 CPU 和 GPU 的共享内存
D3D12_HEAP_PROPERTIES UploadHeapDesc = { D3D12_HEAP_TYPE_UPLOAD };
// 创建常量缓冲资源
m_D3D12Device->CreateCommittedResource(&UploadHeapDesc, D3D12_HEAP_FLAG_NONE, &CBVResourceDesc,
D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&m_CBVResource));
// 常量缓冲直接 Map 映射到结构体指针就行即可,无需 Unmap 关闭映射,Map-Unmap 是耗时操作,对于动态数据我们都只需要 Map 一次就行,然后直接对指针修改数据,这样就实现了常量缓冲数据的修改
// 因为我们每帧都要变换 MVP 矩阵,每帧都要对 MVPBuffer 进行修改,所以我们直接将上传堆资源地址映射到结构体指针
// 每帧直接对指针进行修改操作,不用再进行 Unmap 了,这样着色器每帧都能读取到修改后的数据了
m_CBVResource->Map(0, nullptr, reinterpret_cast<void**>(&MVPBuffer));
XMFLOAT4x4 是 DirectXMath 中专门用于存储 4x4 矩阵的数据类型,它可以存储 4x4=16 个 float 类型的数据。
很重要的一点是,常量缓冲区存储的就是经常要变化的数据,所以 CPU 端需要经常将数据写入到常量缓冲资源中,常量缓冲资源 CBVResource 进行 Map 开放映射后,也就无需再 Unmap 关闭映射了 (每次 Map-Unmap 都是相当耗时的操作)。
也许你会问:这个资源不是在上传堆中吗?CPU 和 GPU 都可以访问,不会发生资源冲突吗? 这一点在我们的程序上是不会的,因为我们把 UpdateConstantBuffer 这一 CPU 端写入资源的操作 (下文会讲),放进了 Render 函数里面,我们已经在代码层面保证了 CPU 与 GPU 会同步执行 (MsgWaitForMultiObjects),所以 GPU 一定会在 CPU 写入后再读取,不会有资源冲突。不过我们后续会引入更复杂的多线程渲染,如何处理好资源同步与状态管理,确实是后面我们要讨论的话题。
修改着色器代码:shader.hlsl
我们在 CPU 端创建了常量缓冲,如何让 GPU (着色器) 知悉并使用常量缓冲呢?这个时候我们就要修改着色器代码了,首先我们需要在 hlsl 的全局区域声明一个 cbuffer 常量缓冲:
// Constant Buffer 常量缓冲,常量缓冲是预先分配的一段高速显存,存放每一帧都要变换的数据,例如我们这里的 MVP 变换矩阵
// 常量缓冲对所有着色器都是只读的,着色器不可以修改常量缓冲里面的内容
cbuffer GlobalData : register(b0, space0) // 常量缓冲,b 表示 buffer 缓冲,b0 表示 0 号 CBV 寄存器,space0 表示使用 b0 的 0 号空间
{
row_major float4x4 MVP; // MVP 矩阵,用于将顶点坐标从模型空间变换到齐次裁剪空间,HLSL 默认按列存储,row_major 表示数据按行存储
}
注意:虽然在 HLSL 中, cbuffer 的写法类似 struct 结构体,但它是一块缓冲,不是结构体!里面的 MVP 常量前面要加上修饰符 row_major,是因为 HLSL 默认按列存储,而我们在 CPU 是逐行写入矩阵数据的,所以要在前面加上 row_major,显式指定按行存储。
拓展知识:Constant Buffer 常量缓冲区名字的由来
这个名字来源于 C/C++ 内存结构中的 “常量存储区”,常量存储区内的数据只读、不可修改。着色器中的常量缓冲也具有相似的特性:着色器运行期间,缓冲内数据只读,不可修改。所以被冠名 “常量缓冲”。
着色器写上了 cbuffer 之后,就可以使用 mul 函数 (HLSL 还有很多类似这种的内置数学函数,可以自行找资料查查) 将顶点与 MVP 矩阵相乘,变换到齐次裁剪空间等待裁剪了:
// Vertex Shader 顶点着色器入口函数 (逐顶点输入),接收来自 IA 阶段输入的顶点数据,处理并返回齐次裁剪空间下的顶点坐标
// 上一阶段:Input Assembler 输入装配阶段
// 下一阶段:Rasterization 光栅化阶段
VSOutput VSMain(VSInput input)
{
VSOutput output;
output.position = mul(input.position, MVP); // 注意这里!顶点坐标需要经过一次 MVP 变换!
output.texcoordUV = input.texcoordUV; // 纹理 UV 不用,照常输出即可
return output;
}
修改根签名:CreateRootSignature
在 CPU、GPU 两端创建完常量缓冲后,下一步就是资源绑定,修改根签名,填写根参数,让常量缓冲绑定到对应的寄存器上。
与绑定纹理用的 SRV 根描述表不同,本次绑定 CBV 资源,我们使用 Root Descriptor 根描述符,根描述符的好处是内联资源,使用方便,无需创建描述符与描述符堆 (因为它自身就是个内联描述符)。
切记!根描述符不可用于绑定纹理的 SRV !
// 第二个根参数:CBV 根描述符,根描述符是内联描述符,所以下文绑定根参数时,只需要传递常量缓冲资源的地址即可
D3D12_ROOT_DESCRIPTOR CBVRootDescriptorDesc = {}; // 常量缓冲根描述符信息结构体
CBVRootDescriptorDesc.ShaderRegister = 0; // 要绑定的寄存器编号,这里对应 HLSL 的 b0 寄存器
CBVRootDescriptorDesc.RegisterSpace = 0; // 要绑定的命名空间,这里对应 HLSL 的 space0
RootParameters[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL; // 常量缓冲对整个渲染管线都可见
RootParameters[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV; // 第二个根参数的类型:CBV 根描述符
RootParameters[1].Descriptor = CBVRootDescriptorDesc; // 填上文的结构体
我们现在有两个根参数:
- 第一个参数:用于绑定纹理的 SRV 根描述表;
- 第二个参数:用于绑定常量缓冲的 CBV 根描述符;
有了这两个根参数,我们就可以填写最终的根签名,绑定资源到着色器上了:
ComPtr<ID3DBlob> SignatureBlob; // 根签名字节码
ComPtr<ID3DBlob> ErrorBlob; // 错误字节码,根签名创建失败时用 OutputDebugStringA((const char*)ErrorBlob->GetBufferPointer()); 可以获取报错信息
D3D12_ROOT_PARAMETER RootParameters[2] = {}; // 根参数数组
// 第一个根参数:根描述表 (Range: SRV)
D3D12_DESCRIPTOR_RANGE SRVDescriptorRangeDesc = {}; // Range 描述符范围结构体,一块 Range 表示一堆连续的同类型描述符
SRVDescriptorRangeDesc.RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SRV; // Range 类型,这里指定 SRV 类型,CBV_SRV_UAV 在这里分流
SRVDescriptorRangeDesc.NumDescriptors = 1; // Range 里面的描述符数量 N,一次可以绑定多个描述符到多个寄存器槽上
SRVDescriptorRangeDesc.BaseShaderRegister = 0; // Range 要绑定的起始寄存器槽编号 i,绑定范围是 [s(i),s(i+N)],我们绑定 s0
SRVDescriptorRangeDesc.RegisterSpace = 0; // Range 要绑定的寄存器空间,整个 Range 都会绑定到同一寄存器空间上,我们绑定 space0
SRVDescriptorRangeDesc.OffsetInDescriptorsFromTableStart = 0; // Range 到根描述表开头的偏移量 (单位:描述符),根签名需要用它来寻找 Range 的地址,我们这填 0 就行
D3D12_ROOT_DESCRIPTOR_TABLE RootDescriptorTableDesc = {}; // RootDescriptorTable 根描述表信息结构体,一个 Table 可以有多个 Range
RootDescriptorTableDesc.pDescriptorRanges = &SRVDescriptorRangeDesc; // Range 描述符范围指针
RootDescriptorTableDesc.NumDescriptorRanges = 1; // 根描述表中 Range 的数量
RootParameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; // 根参数在着色器中的可见性,这里指定仅在像素着色器可见 (只有像素着色器用到了纹理)
RootParameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE; // 根参数类型,这里我们选 Table 根描述表,一个根描述表占用 1 DWORD
RootParameters[0].DescriptorTable = RootDescriptorTableDesc; // 根参数指针
// 第二个根参数:CBV 根描述符,根描述符是内联描述符,所以下文绑定根参数时,只需要传递常量缓冲资源的地址即可
D3D12_ROOT_DESCRIPTOR CBVRootDescriptorDesc = {}; // 常量缓冲根描述符信息结构体
CBVRootDescriptorDesc.ShaderRegister = 0; // 要绑定的寄存器编号,这里对应 HLSL 的 b0 寄存器
CBVRootDescriptorDesc.RegisterSpace = 0; // 要绑定的命名空间,这里对应 HLSL 的 space0
RootParameters[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL; // 常量缓冲对整个渲染管线都可见
RootParameters[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV; // 第二个根参数的类型:CBV 根描述符
RootParameters[1].Descriptor = CBVRootDescriptorDesc; // 填上文的结构体
D3D12_STATIC_SAMPLER_DESC StaticSamplerDesc = {}; // 静态采样器结构体,静态采样器不会占用根签名
StaticSamplerDesc.ShaderRegister = 0; // 要绑定的寄存器槽,对应 s0
StaticSamplerDesc.RegisterSpace = 0; // 要绑定的寄存器空间,对应 space0
StaticSamplerDesc.ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; // 静态采样器在着色器中的可见性,这里指定仅在像素着色器可见 (只有像素着色器用到了纹理采样)
StaticSamplerDesc.Filter = D3D12_FILTER_COMPARISON_MIN_MAG_MIP_POINT; // 纹理过滤类型,这里我们直接选 邻近点采样 就行
StaticSamplerDesc.AddressU = D3D12_TEXTURE_ADDRESS_MODE_BORDER; // 在 U 方向上的纹理寻址方式
StaticSamplerDesc.AddressV = D3D12_TEXTURE_ADDRESS_MODE_BORDER; // 在 V 方向上的纹理寻址方式
StaticSamplerDesc.AddressW = D3D12_TEXTURE_ADDRESS_MODE_BORDER; // 在 W 方向上的纹理寻址方式 (3D 纹理会用到)
StaticSamplerDesc.MinLOD = 0; // 最小 LOD 细节层次,这里我们默认填 0 就行
StaticSamplerDesc.MaxLOD = D3D12_FLOAT32_MAX; // 最大 LOD 细节层次,这里我们默认填 D3D12_FLOAT32_MAX (没有 LOD 上限)
StaticSamplerDesc.MipLODBias = 0; // 基础 Mipmap 采样偏移量,我们这里我们直接填 0 就行
StaticSamplerDesc.MaxAnisotropy = 1; // 各向异性过滤等级,我们不使用各向异性过滤,需要默认填 1
StaticSamplerDesc.ComparisonFunc = D3D12_COMPARISON_FUNC_NEVER; // 这个是用于阴影贴图的,我们不需要用它,所以填 D3D12_COMPARISON_FUNC_NEVER
D3D12_ROOT_SIGNATURE_DESC rootsignatureDesc = {}; // 根签名信息结构体,上限 64 DWORD,静态采样器不占用根签名
rootsignatureDesc.NumParameters = 2; // 根参数数量
rootsignatureDesc.pParameters = RootParameters; // 根参数指针
rootsignatureDesc.NumStaticSamplers = 1; // 静态采样器数量
rootsignatureDesc.pStaticSamplers = &StaticSamplerDesc; // 静态采样器指针
// 根签名标志,可以设置渲染管线不同阶段下的输入参数状态。注意这里!我们要从 IA 阶段输入顶点数据,所以要通过根签名,设置渲染管线允许从 IA 阶段读入数据
rootsignatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
// 编译根签名,让根签名先编译成 GPU 可读的二进制字节码
D3D12SerializeRootSignature(&rootsignatureDesc, D3D_ROOT_SIGNATURE_VERSION_1_0, &SignatureBlob, &ErrorBlob);
if (ErrorBlob) // 如果根签名编译出错,ErrorBlob 可以提供报错信息
{
OutputDebugStringA((const char*)ErrorBlob->GetBufferPointer());
OutputDebugStringA("\n");
}
// 用这个二进制字节码创建根签名对象
m_D3D12Device->CreateRootSignature(0, SignatureBlob->GetBufferPointer(), SignatureBlob->GetBufferSize(), IID_PPV_ARGS(&m_RootSignature));
修改顶点资源:CreateVertexResource
画一个方块,我们需要填 24 个顶点 (每个面 4 个顶点),DirectX 12 使用左手坐标系, x \bm{x} x 轴朝右, y \bm{y} y 轴朝上, z \bm{z} z 轴朝后,请伸出你的左手多比比,否则就会贴错纹理!
// CPU 高速缓存上的顶点信息数组,注意 DirectX 使用的是左手坐标系,写顶点信息时,请比一比你的左手!
VERTEX vertexs[24] =
{
// 正面
{{0,2,0,1},{0,0}},
{{2,2,0,1},{1,0}},
{{2,0,0,1},{1,1}},
{{0,0,0,1},{0,1}},
// 背面
{{2,2,2,1},{0,0}},
{{0,2,2,1},{1,0}},
{{0,0,2,1},{1,1}},
{{2,0,2,1},{0,1}},
// 左面
{{0,2,2,1},{0,0}},
{{0,2,0,1},{1,0}},
{{0,0,0,1},{1,1}},
{{0,0,2,1},{0,1}},
// 右面
{{2,2,0,1},{0,0}},
{{2,2,2,1},{1,0}},
{{2,0,2,1},{1,1}},
{{2,0,0,1},{0,1}},
// 上面
{{0,2,2,1},{0,0}},
{{2,2,2,1},{1,0}},
{{2,2,0,1},{1,1}},
{{0,2,0,1},{0,1}},
// 下面
{{0,0,0,1},{0,0}},
{{2,0,0,1},{1,0}},
{{2,0,2,1},{1,1}},
{{0,0,2,1},{0,1}}
};
D3D12_RESOURCE_DESC VertexDesc = {}; // D3D12Resource 信息结构体
VertexDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; // 资源类型,上传堆的资源类型都是 buffer 缓冲
VertexDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; // 资源布局,指定资源的存储方式,上传堆的资源都是 row major 按行线性存储
VertexDesc.Width = sizeof(vertexs); // 资源宽度,上传堆的资源宽度是资源的总大小
VertexDesc.Height = 1; // 资源高度,上传堆仅仅是传递线性资源的,所以高度必须为 1
VertexDesc.Format = DXGI_FORMAT_UNKNOWN; // 资源格式,上传堆资源的格式必须为 UNKNOWN
VertexDesc.DepthOrArraySize = 1; // 资源深度,这个是用于纹理数组和 3D 纹理的,上传堆资源必须为 1
VertexDesc.MipLevels = 1; // Mipmap 等级,这个是用于纹理的,上传堆资源必须为 1
VertexDesc.SampleDesc.Count = 1; // 资源采样次数,上传堆资源都是填 1
// 上传堆属性的结构体,上传堆位于 CPU 和 GPU 的共享内存
D3D12_HEAP_PROPERTIES UploadHeapDesc = { D3D12_HEAP_TYPE_UPLOAD };
// 创建资源,CreateCommittedResource 会为资源自动创建一个等大小的隐式堆,这个隐式堆的所有权由操作系统管理,开发者不可控制
m_D3D12Device->CreateCommittedResource(&UploadHeapDesc, D3D12_HEAP_FLAG_NONE,
&VertexDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&m_VertexResource));
// 用于传递资源的指针
BYTE* TransferPointer = nullptr;
// Map 开始映射,Map 方法会得到这个 D3D12Resource 的地址 (在共享内存上),传递给指针,这样我们就能通过 memcpy 操作复制数据了
m_VertexResource->Map(0, nullptr, reinterpret_cast<void**>(&TransferPointer));
// 将 CPU 高速缓存上的顶点数据 复制到 共享内存上的 D3D12Resource ,CPU 高速缓存 -> 共享内存
memcpy(TransferPointer, vertexs, sizeof(vertexs));
// Unmap 结束映射,D3D12Resource 变成只读状态,这样做能加速 GPU 的访问
m_VertexResource->Unmap(0, nullptr);
// 填写 VertexBufferView VBV 顶点缓冲描述符,描述上面的 D3D12Resource,让 GPU 知道这是一个顶点缓冲
VertexBufferView.BufferLocation = m_VertexResource->GetGPUVirtualAddress(); // 顶点缓冲资源的地址
VertexBufferView.SizeInBytes = sizeof(vertexs); // 整个顶点缓冲的总大小
VertexBufferView.StrideInBytes = sizeof(VERTEX); // 每个顶点元素的大小 (步长)
创建索引资源:CreateIndexResource
大部分 3D 资源都会使用顶点索引,顶点索引可以大幅度减少顶点数量 (减少了重复顶点),节约内存与显存,减少向 GPU 传递的数据量,提高渲染效率。
和 VertexBufferView 顶点缓冲描述符一样,我们也需要填写 IndexBufferView 索引缓冲描述符,这样原本方块要画 36 个顶点,有了索引缓冲就能砍到 24 个,GPU 只需要照索引进行渲染就好了:
ComPtr<ID3D12Resource> m_IndexResource; // 索引资源
D3D12_INDEX_BUFFER_VIEW IndexBufferView = {}; // 索引缓冲描述符
// 顶点索引数组,注意这里的 UINT == UINT32,后面填的格式 (步长) 必须是 DXGI_FORMAT_R32_UINT,否则会出错
UINT IndexArray[36] =
{
// 正面
0,1,2,0,2,3,
// 背面
4,5,6,4,6,7,
// 左面
8,9,10,8,10,11,
// 右面
12,13,14,12,14,15,
// 上面
16,17,18,16,18,19,
// 下面
20,21,22,20,22,23
};
D3D12_RESOURCE_DESC IndexResDesc = {}; // D3D12Resource 信息结构体
IndexResDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; // 资源类型,上传堆的资源类型都是 buffer 缓冲
IndexResDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; // 资源布局,指定资源的存储方式,上传堆的资源都是 row major 按行线性存储
IndexResDesc.Width = sizeof(IndexArray); // 资源宽度,上传堆的资源宽度是资源的总大小
IndexResDesc.Height = 1; // 资源高度,上传堆仅仅是传递线性资源的,所以高度必须为 1
IndexResDesc.Format = DXGI_FORMAT_UNKNOWN; // 资源格式,上传堆资源的格式必须为 UNKNOWN
IndexResDesc.DepthOrArraySize = 1; // 资源深度,这个是用于纹理数组和 3D 纹理的,上传堆资源必须为 1
IndexResDesc.MipLevels = 1; // Mipmap 等级,这个是用于纹理的,上传堆资源必须为 1
IndexResDesc.SampleDesc.Count = 1; // 资源采样次数,上传堆资源都是填 1
// 上传堆属性的结构体,上传堆位于 CPU 和 GPU 的共享内存
D3D12_HEAP_PROPERTIES UploadHeapDesc = { D3D12_HEAP_TYPE_UPLOAD };
// 创建资源,CreateCommittedResource 会为资源自动创建一个等大小的隐式堆,这个隐式堆的所有权由操作系统管理,开发者不可控制
m_D3D12Device->CreateCommittedResource(&UploadHeapDesc, D3D12_HEAP_FLAG_NONE,
&IndexResDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&m_IndexResource));
// 用于传递资源的指针
BYTE* TransferPointer = nullptr;
// Map 开始映射,Map 方法会得到这个 D3D12Resource 的地址 (在共享内存上),传递给指针,这样我们就能通过 memcpy 操作复制数据了
m_IndexResource->Map(0, nullptr, reinterpret_cast<void**>(&TransferPointer));
// 将 CPU 高速缓存上的索引数据 复制到 共享内存上的 D3D12Resource ,CPU 高速缓存 -> 共享内存
memcpy(TransferPointer, IndexArray, sizeof(IndexArray));
// Unmap 结束映射,D3D12Resource 变成只读状态,这样做能加速 GPU 的访问
m_IndexResource->Unmap(0, nullptr);
// 填写 IndexBufferView IBV 索引缓冲描述符,描述上面的 D3D12Resource,让 GPU 知道这是一个索引缓冲
IndexBufferView.BufferLocation = m_IndexResource->GetGPUVirtualAddress(); // 索引缓冲资源的地址
IndexBufferView.SizeInBytes = sizeof(IndexArray); // 整个索引缓冲的总大小
IndexBufferView.Format = DXGI_FORMAT_R32_UINT; // 每个索引的大小 (步长),不同类型的数据,索引的格式是固定的
更新常量缓冲区:UpdateConstantBuffer
接下来,我们需要更新 MVP 矩阵,将新的 MVP 矩阵写入常量缓冲区,然而 MVP 矩阵需要我们手动实现吗?无需担心!DirectXMath 库已经帮我们准备好了工具函数,我们只需要会用就行了:
XMVECTOR EyePosition = XMVectorSet(4, 3, 4, 1); // 摄像机在世界空间下的位置
XMVECTOR FocusPosition = XMVectorSet(0, 1, 1, 1); // 摄像机在世界空间下观察的焦点位置
XMVECTOR UpDirection = XMVectorSet(0, 1, 0, 0); // 世界空间垂直向上的向量
XMMATRIX ModelMatrix; // 模型矩阵,模型空间 -> 世界空间
XMMATRIX ViewMatrix; // 观察矩阵,世界空间 -> 观察空间
XMMATRIX ProjectionMatrix; // 投影矩阵,观察空间 -> 齐次裁剪空间
// 更新模型矩阵,这里我们让模型旋转 30° 就行
ModelMatrix = XMMatrixRotationY(30.0f);
// 更新观察矩阵,注意前两个参数是点,第三个参数才是向量
ViewMatrix = XMMatrixLookAtLH(EyePosition, FocusPosition, UpDirection);
// 更新投影矩阵 (垂直视场角是 pi/4,投影窗口宽高比是 4:3,近平面距离是 0.1,远平面距离是 1000,注意近平面和远平面距离不能为 0!)
ProjectionMatrix = XMMatrixPerspectiveFovLH(XM_PIDIV4, 4.0 / 3, 0.1, 1000);
// 将更新后的矩阵,存储到共享内存上的常量缓冲,这样 GPU 就可以访问到 MVP 矩阵了
XMStoreFloat4x4(&MVPBuffer->MVPMatrix, ModelMatrix * ViewMatrix * ProjectionMatrix);
XMMatrixLookAtLH 左手坐标系下构建观察矩阵
LookAt 表示用 (摄像机位置 + 摄像机焦点 + 世界空间向上向量) 二点一向量构建,LH 表示左手坐标系
第一个参数 EyePosition 摄像机在世界空间下的位置
第二个参数 FoucusPosition 摄像机在世界空间下观察的焦点位置
第三个参数 UpDirection 世界空间垂直向上的向量
XMMatrixPerspectiveFovLH 左手坐标系下构建透视投影矩阵
Perspective 表示透视投影,Fov 表示 Field of View 视场角,LH 表示左手坐标系
第一个参数 FovAngleY 垂直视场角,这里填 XM_PIDIV4 45° 角
第二个参数 AspectRatio 投影窗口的宽高比,我们的窗口宽高比是 4:3,投影窗口也要一致,所以填 4.0/3
第三个参数 NearZ 近平面相对原点的距离
第四个参数 FarZ 远平面相对原点的距离
近平面和远平面距离都需要大于 0,否则运行时会抛出异常!
修改渲染代码:Render
最后一步是修改渲染代码,向命令队列发出绑定两个根参数、绑定 VeretxBufferView 与 IndexBufferView、按索引渲染的一系列命令,就大功告成了!
// 每帧渲染开始前,调用 UpdateConstantBuffer() 更新常量缓冲区
UpdateConstantBuffer();
// 获取 RTV 堆首句柄
RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
// 获取当前渲染的后台缓冲序号
FrameIndex = m_DXGISwapChain->GetCurrentBackBufferIndex();
// 偏移 RTV 句柄,找到对应的 RTV 描述符
RTVHandle.ptr += FrameIndex * RTVDescriptorSize;
// 先重置命令分配器
m_CommandAllocator->Reset();
// 再重置命令列表,Close 关闭状态 -> Record 录制状态
m_CommandList->Reset(m_CommandAllocator.Get(), nullptr);
// 将起始转换屏障的资源指定为当前渲染目标
beg_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
// 调用资源屏障,将渲染目标由 Present 呈现(只读) 转换到 RenderTarget 渲染目标(只写)
m_CommandList->ResourceBarrier(1, &beg_barrier);
// 第二次设置根签名!本次设置将会检查 渲染管线绑定的根签名 与 这里的根签名 是否匹配
// 以及根签名指定的资源是否被正确绑定,检查完毕后会进行简单的映射
m_CommandList->SetGraphicsRootSignature(m_RootSignature.Get());
// 设置渲染管线状态,可以在上面 m_CommandList->Reset() 的时候直接在第二个参数设置 PSO
m_CommandList->SetPipelineState(m_PipelineStateObject.Get());
// 设置视口 (光栅化阶段),用于光栅化里的屏幕映射
m_CommandList->RSSetViewports(1, &viewPort);
// 设置裁剪矩形 (光栅化阶段)
m_CommandList->RSSetScissorRects(1, &ScissorRect);
// 用 RTV 句柄设置渲染目标
m_CommandList->OMSetRenderTargets(1, &RTVHandle, false, nullptr);
// 清空当前渲染目标的背景为天蓝色
m_CommandList->ClearRenderTargetView(RTVHandle, DirectX::Colors::SkyBlue, 0, nullptr);
// 用于设置描述符堆用的临时 ID3D12DescriptorHeap 数组
ID3D12DescriptorHeap* _temp_DescriptorHeaps[] = { m_SRVHeap.Get() };
// 设置描述符堆
m_CommandList->SetDescriptorHeaps(1, _temp_DescriptorHeaps);
// 设置 SRV 句柄 (第一个根参数)
m_CommandList->SetGraphicsRootDescriptorTable(0, SRV_GPUHandle);
// 设置常量缓冲 (第二个根参数),我们复制完数据到 CBVResource 后,就可以让着色器读取、对顶点进行 MVP 变换了
m_CommandList->SetGraphicsRootConstantBufferView(1, m_CBVResource->GetGPUVirtualAddress());
// 设置图元拓扑 (输入装配阶段),我们这里设置三角形列表
m_CommandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// 设置 VBV 顶点缓冲描述符 (输入装配阶段)
m_CommandList->IASetVertexBuffers(0, 1, &VertexBufferView);
// 设置 IBV 索引缓冲描述符 (输入装配阶段)
m_CommandList->IASetIndexBuffer(&IndexBufferView);
// Draw Call! 绘制方块
m_CommandList->DrawIndexedInstanced(36, 1, 0, 0, 0);
// 将终止转换屏障的资源指定为当前渲染目标
end_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
// 再通过一次资源屏障,将渲染目标由 RenderTarget 渲染目标(只写) 转换到 Present 呈现(只读)
m_CommandList->ResourceBarrier(1, &end_barrier);
// 关闭命令列表,Record 录制状态 -> Close 关闭状态,命令列表只有关闭才可以提交
m_CommandList->Close();
// 用于传递命令用的临时 ID3D12CommandList 数组
ID3D12CommandList* _temp_cmdlists[] = { m_CommandList.Get() };
// 执行上文的渲染命令!
m_CommandQueue->ExecuteCommandLists(1, _temp_cmdlists);
// 向命令队列发出交换缓冲的命令,此命令会加入到命令队列中,命令队列执行到该命令时,会通知交换链交换缓冲
m_DXGISwapChain->Present(1, NULL);
// 将围栏预定值设定为下一帧
FenceValue++;
// 在命令队列 (命令队列在 GPU 端) 设置围栏预定值,此命令会加入到命令队列中
// 命令队列执行到这里会修改围栏值,表示渲染已完成,"击中"围栏
m_CommandQueue->Signal(m_Fence.Get(), FenceValue);
// 设置围栏的预定事件,当渲染完成时,围栏被"击中",激发预定事件,将事件由无信号状态转换成有信号状态
m_Fence->SetEventOnCompletion(FenceValue, RenderEvent);
DrawIndexedInstanced 按索引绘制顶点
第一个参数 IndexCountPerInstance 每个实例需要绘制的顶点索引数
第二个参数 InstanceCount 需要绘制的实例数
第三个参数 StartIndexLocation 索引缓冲区内起始绘制索引的位置
第四个参数 BaseVertexLocation 顶点缓冲区内起始绘制顶点的位置
第五个参数 StartInstanceLocation 起始绘制的实例索引
第五节全代码
main.cpp
// (5) DrawBlock:用 DirectX 12 画一个钻石原矿方块
#include<Windows.h> // Windows 窗口编程核心头文件
#include<d3d12.h> // DX12 核心头文件
#include<dxgi1_6.h> // DXGI 头文件,用于管理与 DX12 相关联的其他必要设备,如 DXGI 工厂和 交换链
#include<DirectXColors.h> // DirectX 颜色库
#include<DirectXMath.h> // DirectX 数学库
#include<d3dcompiler.h> // DirectX Shader 着色器编译库
#include<wincodec.h> // WIC 图像处理框架,用于解码编码转换图片文件
#include<wrl.h> // COM 组件模板库,方便写 DX12 和 DXGI 相关的接口
#include<string> // C++ 标准 string 库
#include<sstream> // C++ 字符串流处理库
#pragma comment(lib,"d3d12.lib") // 链接 DX12 核心 DLL
#pragma comment(lib,"dxgi.lib") // 链接 DXGI DLL
#pragma comment(lib,"dxguid.lib") // 链接 DXGI 必要的设备 GUID
#pragma comment(lib,"d3dcompiler.lib") // 链接 DX12 需要的着色器编译 DLL
#pragma comment(lib,"windowscodecs.lib") // 链接 WIC DLL
using namespace Microsoft;
using namespace Microsoft::WRL; // 使用 wrl.h 里面的命名空间,我们需要用到里面的 Microsoft::WRL::ComPtr COM智能指针
using namespace DirectX; // DirectX 命名空间
// 命名空间 DX12TextureHelper 包含了帮助我们转换纹理图片格式的结构体与函数
namespace DX12TextureHelper
{
// 纹理转换用,不是 DX12 所支持的格式,DX12 没法用
// Standard GUID -> DXGI 格式转换结构体
struct WICTranslate
{
GUID wic;
DXGI_FORMAT format;
};
// WIC 格式与 DXGI 像素格式的对应表,该表中的格式为被支持的格式
static WICTranslate g_WICFormats[] =
{
{ GUID_WICPixelFormat128bppRGBAFloat, DXGI_FORMAT_R32G32B32A32_FLOAT },
{ GUID_WICPixelFormat64bppRGBAHalf, DXGI_FORMAT_R16G16B16A16_FLOAT },
{ GUID_WICPixelFormat64bppRGBA, DXGI_FORMAT_R16G16B16A16_UNORM },
{ GUID_WICPixelFormat32bppRGBA, DXGI_FORMAT_R8G8B8A8_UNORM },
{ GUID_WICPixelFormat32bppBGRA, DXGI_FORMAT_B8G8R8A8_UNORM },
{ GUID_WICPixelFormat32bppBGR, DXGI_FORMAT_B8G8R8X8_UNORM },
{ GUID_WICPixelFormat32bppRGBA1010102XR, DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM },
{ GUID_WICPixelFormat32bppRGBA1010102, DXGI_FORMAT_R10G10B10A2_UNORM },
{ GUID_WICPixelFormat16bppBGRA5551, DXGI_FORMAT_B5G5R5A1_UNORM },
{ GUID_WICPixelFormat16bppBGR565, DXGI_FORMAT_B5G6R5_UNORM },
{ GUID_WICPixelFormat32bppGrayFloat, DXGI_FORMAT_R32_FLOAT },
{ GUID_WICPixelFormat16bppGrayHalf, DXGI_FORMAT_R16_FLOAT },
{ GUID_WICPixelFormat16bppGray, DXGI_FORMAT_R16_UNORM },
{ GUID_WICPixelFormat8bppGray, DXGI_FORMAT_R8_UNORM },
{ GUID_WICPixelFormat8bppAlpha, DXGI_FORMAT_A8_UNORM }
};
// GUID -> Standard GUID 格式转换结构体
struct WICConvert
{
GUID source;
GUID target;
};
// WIC 像素格式转换表
static WICConvert g_WICConvert[] =
{
// 目标格式一定是最接近的被支持的格式
{ GUID_WICPixelFormatBlackWhite, GUID_WICPixelFormat8bppGray }, // DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat1bppIndexed, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat2bppIndexed, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat4bppIndexed, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat8bppIndexed, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat2bppGray, GUID_WICPixelFormat8bppGray }, // DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat4bppGray, GUID_WICPixelFormat8bppGray }, // DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat16bppGrayFixedPoint, GUID_WICPixelFormat16bppGrayHalf }, // DXGI_FORMAT_R16_FLOAT
{ GUID_WICPixelFormat32bppGrayFixedPoint, GUID_WICPixelFormat32bppGrayFloat }, // DXGI_FORMAT_R32_FLOAT
{ GUID_WICPixelFormat16bppBGR555, GUID_WICPixelFormat16bppBGRA5551 }, // DXGI_FORMAT_B5G5R5A1_UNORM
{ GUID_WICPixelFormat32bppBGR101010, GUID_WICPixelFormat32bppRGBA1010102 }, // DXGI_FORMAT_R10G10B10A2_UNORM
{ GUID_WICPixelFormat24bppBGR, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat24bppRGB, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat32bppPBGRA, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat32bppPRGBA, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat48bppRGB, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat48bppBGR, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppBGRA, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppPRGBA, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppPBGRA, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat48bppRGBFixedPoint, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat48bppBGRFixedPoint, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBAFixedPoint, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppBGRAFixedPoint, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBFixedPoint, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat48bppRGBHalf, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBHalf, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat128bppPRGBAFloat, GUID_WICPixelFormat128bppRGBAFloat }, // DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat128bppRGBFloat, GUID_WICPixelFormat128bppRGBAFloat }, // DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat128bppRGBAFixedPoint, GUID_WICPixelFormat128bppRGBAFloat }, // DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat128bppRGBFixedPoint, GUID_WICPixelFormat128bppRGBAFloat }, // DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat32bppRGBE, GUID_WICPixelFormat128bppRGBAFloat }, // DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat32bppCMYK, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat64bppCMYK, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat40bppCMYKAlpha, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat80bppCMYKAlpha, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat32bppRGB, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat64bppRGB, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat64bppPRGBAHalf, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat128bppRGBAFloat, GUID_WICPixelFormat128bppRGBAFloat }, // DXGI_FORMAT_R32G32B32A32_FLOAT
{ GUID_WICPixelFormat64bppRGBAHalf, GUID_WICPixelFormat64bppRGBAHalf }, // DXGI_FORMAT_R16G16B16A16_FLOAT
{ GUID_WICPixelFormat64bppRGBA, GUID_WICPixelFormat64bppRGBA }, // DXGI_FORMAT_R16G16B16A16_UNORM
{ GUID_WICPixelFormat32bppRGBA, GUID_WICPixelFormat32bppRGBA }, // DXGI_FORMAT_R8G8B8A8_UNORM
{ GUID_WICPixelFormat32bppBGRA, GUID_WICPixelFormat32bppBGRA }, // DXGI_FORMAT_B8G8R8A8_UNORM
{ GUID_WICPixelFormat32bppBGR, GUID_WICPixelFormat32bppBGR }, // DXGI_FORMAT_B8G8R8X8_UNORM
{ GUID_WICPixelFormat32bppRGBA1010102XR, GUID_WICPixelFormat32bppRGBA1010102XR },// DXGI_FORMAT_R10G10B10_XR_BIAS_A2_UNORM
{ GUID_WICPixelFormat32bppRGBA1010102, GUID_WICPixelFormat32bppRGBA1010102 }, // DXGI_FORMAT_R10G10B10A2_UNORM
{ GUID_WICPixelFormat16bppBGRA5551, GUID_WICPixelFormat16bppBGRA5551 }, // DXGI_FORMAT_B5G5R5A1_UNORM
{ GUID_WICPixelFormat16bppBGR565, GUID_WICPixelFormat16bppBGR565 }, // DXGI_FORMAT_B5G6R5_UNORM
{ GUID_WICPixelFormat32bppGrayFloat, GUID_WICPixelFormat32bppGrayFloat }, // DXGI_FORMAT_R32_FLOAT
{ GUID_WICPixelFormat16bppGrayHalf, GUID_WICPixelFormat16bppGrayHalf }, // DXGI_FORMAT_R16_FLOAT
{ GUID_WICPixelFormat16bppGray, GUID_WICPixelFormat16bppGray }, // DXGI_FORMAT_R16_UNORM
{ GUID_WICPixelFormat8bppGray, GUID_WICPixelFormat8bppGray }, // DXGI_FORMAT_R8_UNORM
{ GUID_WICPixelFormat8bppAlpha, GUID_WICPixelFormat8bppAlpha } // DXGI_FORMAT_A8_UNORM
};
// 查表确定兼容的最接近格式是哪个
bool GetTargetPixelFormat(const GUID* pSourceFormat, GUID* pTargetFormat)
{
*pTargetFormat = *pSourceFormat;
for (size_t i = 0; i < _countof(g_WICConvert); ++i)
{
if (InlineIsEqualGUID(g_WICConvert[i].source, *pSourceFormat))
{
*pTargetFormat = g_WICConvert[i].target;
return true;
}
}
return false; // 找不到,就返回 false
}
// 查表确定最终对应的 DXGI 格式是哪一个
DXGI_FORMAT GetDXGIFormatFromPixelFormat(const GUID* pPixelFormat)
{
for (size_t i = 0; i < _countof(g_WICFormats); ++i)
{
if (InlineIsEqualGUID(g_WICFormats[i].wic, *pPixelFormat))
{
return g_WICFormats[i].format;
}
}
return DXGI_FORMAT_UNKNOWN; // 找不到,就返回 UNKNOWN
}
}
// DX12 引擎
class DX12Engine
{
private:
int WindowWidth = 640; // 窗口宽度
int WindowHeight = 480; // 窗口高度
HWND m_hwnd; // 窗口句柄
ComPtr<ID3D12Debug> m_D3D12DebugDevice; // D3D12 调试层设备
UINT m_DXGICreateFactoryFlag = NULL; // 创建 DXGI 工厂时需要用到的标志
ComPtr<IDXGIFactory5> m_DXGIFactory; // DXGI 工厂
ComPtr<IDXGIAdapter1> m_DXGIAdapter; // 显示适配器 (显卡)
ComPtr<ID3D12Device4> m_D3D12Device; // D3D12 核心设备
ComPtr<ID3D12CommandQueue> m_CommandQueue; // 命令队列
ComPtr<ID3D12CommandAllocator> m_CommandAllocator; // 命令分配器
ComPtr<ID3D12GraphicsCommandList> m_CommandList; // 命令列表
ComPtr<IDXGISwapChain3> m_DXGISwapChain; // DXGI 交换链
ComPtr<ID3D12DescriptorHeap> m_RTVHeap; // RTV 描述符堆
ComPtr<ID3D12Resource> m_RenderTarget[3]; // 渲染目标数组,每一副渲染目标对应一个窗口缓冲区
D3D12_CPU_DESCRIPTOR_HANDLE RTVHandle; // RTV 描述符句柄
UINT RTVDescriptorSize = 0; // RTV 描述符的大小
UINT FrameIndex = 0; // 帧索引,表示当前渲染的第 i 帧 (第 i 个渲染目标)
ComPtr<ID3D12Fence> m_Fence; // 围栏
UINT64 FenceValue = 0; // 用于围栏等待的围栏值
HANDLE RenderEvent = NULL; // GPU 渲染事件
D3D12_RESOURCE_BARRIER beg_barrier = {}; // 渲染开始的资源屏障,呈现 -> 渲染目标
D3D12_RESOURCE_BARRIER end_barrier = {}; // 渲染结束的资源屏障,渲染目标 -> 呈现
std::wstring TextureFilename = L"diamond_ore.png"; // 纹理文件名 (这里用的是相对路径)
ComPtr<IWICImagingFactory> m_WICFactory; // WIC 工厂
ComPtr<IWICBitmapDecoder> m_WICBitmapDecoder; // 位图解码器
ComPtr<IWICBitmapFrameDecode> m_WICBitmapDecodeFrame; // 由解码器得到的单个位图帧
ComPtr<IWICFormatConverter> m_WICFormatConverter; // 位图转换器
ComPtr<IWICBitmapSource> m_WICBitmapSource; // WIC 位图资源,用于获取位图数据
UINT TextureWidth = 0; // 纹理宽度
UINT TextureHeight = 0; // 纹理高度
UINT BitsPerPixel = 0; // 图像深度,图片每个像素占用的比特数
UINT BytePerRowSize = 0; // 纹理每行数据的真实字节大小,用于读取纹理数据、上传纹理资源
DXGI_FORMAT TextureFormat = DXGI_FORMAT_UNKNOWN; // 纹理格式
ComPtr<ID3D12DescriptorHeap> m_SRVHeap; // SRV 描述符堆
D3D12_CPU_DESCRIPTOR_HANDLE SRV_CPUHandle; // SRV 描述符 CPU 句柄
D3D12_GPU_DESCRIPTOR_HANDLE SRV_GPUHandle; // SRV 描述符 GPU 句柄
ComPtr<ID3D12Resource> m_UploadTextureResource; // 上传堆资源,位于共享内存,用于中转纹理资源
ComPtr<ID3D12Resource> m_DefaultTextureResource; // 默认堆资源,位于显存,用于放纹理
UINT TextureSize = 0; // 纹理的真实大小 (单位:字节)
UINT UploadResourceRowSize = 0; // 上传堆资源每行的大小 (单位:字节)
UINT UploadResourceSize = 0; // 上传堆资源的总大小 (单位:字节)
ComPtr<ID3D12Resource> m_CBVResource; // 常量缓冲资源,用于存放 MVP 矩阵,MVP 矩阵每帧都要更新,所以需要存储在常量缓冲区中
struct CBuffer // 常量缓冲结构体
{
XMFLOAT4X4 MVPMatrix; // MVP 矩阵,用于将顶点数据从顶点空间变换到齐次裁剪空间
};
CBuffer* MVPBuffer = nullptr; // 常量缓冲结构体指针,里面存储的是 MVP 矩阵信息,下文 Map 后指针会指向 CBVResource 的地址
XMVECTOR EyePosition = XMVectorSet(4, 3, 4, 1); // 摄像机在世界空间下的位置
XMVECTOR FocusPosition = XMVectorSet(0, 1, 1, 1); // 摄像机在世界空间下观察的焦点位置
XMVECTOR UpDirection = XMVectorSet(0, 1, 0, 0); // 世界空间垂直向上的向量
XMMATRIX ModelMatrix; // 模型矩阵,模型空间 -> 世界空间
XMMATRIX ViewMatrix; // 观察矩阵,世界空间 -> 观察空间
XMMATRIX ProjectionMatrix; // 投影矩阵,观察空间 -> 齐次裁剪空间
ComPtr<ID3D12RootSignature> m_RootSignature; // 根签名
ComPtr<ID3D12PipelineState> m_PipelineStateObject; // 渲染管线状态
ComPtr<ID3D12Resource> m_VertexResource; // 顶点资源
struct VERTEX // 顶点数据结构体
{
XMFLOAT4 position; // 顶点位置
XMFLOAT2 texcoordUV; // 顶点纹理坐标
};
D3D12_VERTEX_BUFFER_VIEW VertexBufferView; // 顶点缓冲描述符
ComPtr<ID3D12Resource> m_IndexResource; // 索引资源
D3D12_INDEX_BUFFER_VIEW IndexBufferView = {}; // 索引缓冲描述符
// 视口
D3D12_VIEWPORT viewPort = D3D12_VIEWPORT{ 0, 0, float(WindowWidth), float(WindowHeight), D3D12_MIN_DEPTH, D3D12_MAX_DEPTH };
// 裁剪矩形
D3D12_RECT ScissorRect = D3D12_RECT{ 0, 0, WindowWidth, WindowHeight };
public:
// 初始化窗口
void InitWindow(HINSTANCE hins)
{
WNDCLASS wc = {}; // 用于记录窗口类信息的结构体
wc.hInstance = hins; // 窗口类需要一个应用程序的实例句柄 hinstance
wc.lpfnWndProc = CallBackFunc; // 窗口类需要一个回调函数,用于处理窗口产生的消息
wc.lpszClassName = L"DX12 Game"; // 窗口类的名称
RegisterClass(&wc); // 注册窗口类,将窗口类录入到操作系统中
// 使用上文的窗口类创建窗口
m_hwnd = CreateWindow(wc.lpszClassName, L"DX12画钻石原矿", WS_SYSMENU | WS_OVERLAPPED,
10, 10, WindowWidth, WindowHeight,
NULL, NULL, hins, NULL);
// 因为指定了窗口大小不可变的 WS_SYSMENU 和 WS_OVERLAPPED,应用不会自动显示窗口,需要使用 ShowWindow 强制显示窗口
ShowWindow(m_hwnd, SW_SHOW);
}
// 创建调试层
void CreateDebugDevice()
{
::CoInitialize(nullptr); // 注意这里!DX12 的所有设备接口都是基于 COM 接口的,我们需要先全部初始化为 nullptr
#if defined(_DEBUG) // 如果是 Debug 模式下编译,就执行下面的代码
// 获取调试层设备接口
D3D12GetDebugInterface(IID_PPV_ARGS(&m_D3D12DebugDevice));
// 开启调试层
m_D3D12DebugDevice->EnableDebugLayer();
// 开启调试层后,创建 DXGI 工厂也需要 Debug Flag
m_DXGICreateFactoryFlag = DXGI_CREATE_FACTORY_DEBUG;
#endif
}
// 创建设备
bool CreateDevice()
{
// 创建 DXGI 工厂
CreateDXGIFactory2(m_DXGICreateFactoryFlag, IID_PPV_ARGS(&m_DXGIFactory));
// DX12 支持的所有功能版本,你的显卡最低需要支持 11.0
const D3D_FEATURE_LEVEL dx12SupportLevel[] =
{
D3D_FEATURE_LEVEL_12_2, // 12.2
D3D_FEATURE_LEVEL_12_1, // 12.1
D3D_FEATURE_LEVEL_12_0, // 12.0
D3D_FEATURE_LEVEL_11_1, // 11.1
D3D_FEATURE_LEVEL_11_0 // 11.0
};
// 用 EnumAdapters1 先遍历电脑上的每一块显卡
// 每次调用 EnumAdapters1 找到显卡会自动创建 DXGIAdapter 接口,并返回 S_OK
// 找不到显卡会返回 ERROR_NOT_FOUND
for (UINT i = 0; m_DXGIFactory->EnumAdapters1(i, &m_DXGIAdapter) != ERROR_NOT_FOUND; i++)
{
// 找到显卡,就创建 D3D12 设备,从高到低遍历所有功能版本,创建成功就跳出
for (const auto& level : dx12SupportLevel)
{
// 创建 D3D12 核心层设备,创建成功就返回 true
if (SUCCEEDED(D3D12CreateDevice(m_DXGIAdapter.Get(), level, IID_PPV_ARGS(&m_D3D12Device))))
{
DXGI_ADAPTER_DESC1 adap = {};
m_DXGIAdapter->GetDesc1(&adap);
OutputDebugStringW(adap.Description);
return true;
}
}
}
// 如果找不到任何能支持 DX12 的显卡,就退出程序
if (m_D3D12Device == nullptr)
{
MessageBox(NULL, L"找不到任何能支持 DX12 的显卡,请升级电脑上的硬件!", L"错误", MB_OK | MB_ICONERROR);
return false;
}
}
// 创建命令三件套
void CreateCommandComponents()
{
// 队列信息结构体,这里只需要填队列的类型 type 就行了
D3D12_COMMAND_QUEUE_DESC queueDesc = {};
// D3D12_COMMAND_LIST_TYPE_DIRECT 表示将命令都直接放进队列里,不做其他处理
queueDesc.Type = D3D12_COMMAND_LIST_TYPE_DIRECT;
// 创建命令队列
m_D3D12Device->CreateCommandQueue(&queueDesc, IID_PPV_ARGS(&m_CommandQueue));
// 创建命令分配器,它的作用是开辟内存,存储命令列表上的命令,注意命令类型要一致
m_D3D12Device->CreateCommandAllocator(D3D12_COMMAND_LIST_TYPE_DIRECT, IID_PPV_ARGS(&m_CommandAllocator));
// 创建图形命令列表,注意命令类型要一致
m_D3D12Device->CreateCommandList(0, D3D12_COMMAND_LIST_TYPE_DIRECT, m_CommandAllocator.Get(),
nullptr, IID_PPV_ARGS(&m_CommandList));
// 命令列表创建时处于 Record 录制状态,我们需要关闭它,这样下文的 Reset 才能成功
m_CommandList->Close();
}
// 创建渲染目标,将渲染目标设置为窗口
void CreateRenderTarget()
{
// 创建 RTV 描述符堆 (Render Target View,渲染目标描述符)
D3D12_DESCRIPTOR_HEAP_DESC RTVHeapDesc = {};
RTVHeapDesc.NumDescriptors = 3; // 渲染目标的数量
RTVHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_RTV; // 描述符堆的类型:RTV
// 创建一个 RTV 描述符堆,创建成功后,会自动开辟三个描述符的内存
m_D3D12Device->CreateDescriptorHeap(&RTVHeapDesc, IID_PPV_ARGS(&m_RTVHeap));
// 创建 DXGI 交换链,用于将窗口缓冲区和渲染目标绑定
DXGI_SWAP_CHAIN_DESC1 swapchainDesc = {};
swapchainDesc.BufferCount = 3; // 缓冲区数量
swapchainDesc.Width = WindowWidth; // 缓冲区 (窗口) 宽度
swapchainDesc.Height = WindowHeight; // 缓冲区 (窗口) 高度
swapchainDesc.Format = DXGI_FORMAT_R8G8B8A8_UNORM; // 缓冲区格式,指定缓冲区每个像素的大小
swapchainDesc.SwapEffect = DXGI_SWAP_EFFECT_FLIP_DISCARD; // 交换链类型,有 FILP 和 BITBLT 两种类型
swapchainDesc.BufferUsage = DXGI_USAGE_RENDER_TARGET_OUTPUT;// 缓冲区的用途,这里表示把缓冲区用作渲染目标的输出
swapchainDesc.SampleDesc.Count = 1; // 缓冲区像素采样次数
// 临时低版本交换链接口,用于创建高版本交换链,因为下文的 CreateSwapChainForHwnd 不能直接用于创建高版本接口
ComPtr<IDXGISwapChain1> _temp_swapchain;
// 创建交换链,将窗口与渲染目标绑定
// 注意:交换链需要绑定到命令队列来刷新,所以第一个参数要填命令队列,不填会创建失败!
m_DXGIFactory->CreateSwapChainForHwnd(m_CommandQueue.Get(), m_hwnd,
&swapchainDesc, nullptr, nullptr, &_temp_swapchain);
// 通过 As 方法,将低版本接口的信息传递给高版本接口
_temp_swapchain.As(&m_DXGISwapChain);
// 创建完交换链后,我们还需要令 RTV 描述符 指向 渲染目标
// 因为 ID3D12Resource 本质上只是一块数据,它本身没有对数据用法的说明
// 我们要让程序知道这块数据是一个渲染目标,就得创建并使用 RTV 描述符
// 获取 RTV 堆指向首描述符的句柄
RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
// 获取 RTV 描述符的大小
RTVDescriptorSize = m_D3D12Device->GetDescriptorHandleIncrementSize(D3D12_DESCRIPTOR_HEAP_TYPE_RTV);
for (UINT i = 0; i < 3; i++)
{
// 从交换链中获取第 i 个窗口缓冲,创建第 i 个 RenderTarget 渲染目标
m_DXGISwapChain->GetBuffer(i, IID_PPV_ARGS(&m_RenderTarget[i]));
// 创建 RTV 描述符,将渲染目标绑定到描述符上
m_D3D12Device->CreateRenderTargetView(m_RenderTarget[i].Get(), nullptr, RTVHandle);
// 偏移到下一个 RTV 句柄
RTVHandle.ptr += RTVDescriptorSize;
}
}
// 创建围栏和资源屏障,用于 CPU-GPU 的同步
void CreateFenceAndBarrier()
{
// 创建 CPU 上的等待事件
RenderEvent = CreateEvent(nullptr, false, true, nullptr);
// 创建围栏,设定初始值为 0
m_D3D12Device->CreateFence(FenceValue, D3D12_FENCE_FLAG_NONE, IID_PPV_ARGS(&m_Fence));
// 设置资源屏障
// beg_barrier 起始屏障:Present 呈现状态 -> Render Target 渲染目标状态
beg_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION; // 指定类型为转换屏障
beg_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_PRESENT;
beg_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_RENDER_TARGET;
// end_barrier 终止屏障:Render Target 渲染目标状态 -> Present 呈现状态
end_barrier.Type = D3D12_RESOURCE_BARRIER_TYPE_TRANSITION;
end_barrier.Transition.StateBefore = D3D12_RESOURCE_STATE_RENDER_TARGET;
end_barrier.Transition.StateAfter = D3D12_RESOURCE_STATE_PRESENT;
}
// 加载纹理到内存中
bool LoadTextureFromFile()
{
// 如果还没创建 WIC 工厂,就新建一个 WIC 工厂实例。注意!WIC 工厂不可以重复释放与创建!
if (m_WICFactory == nullptr) CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&m_WICFactory));
// 创建图片解码器,并将图片读入到解码器中
HRESULT hr = m_WICFactory->CreateDecoderFromFilename(TextureFilename.c_str(), nullptr, GENERIC_READ, WICDecodeMetadataCacheOnDemand, &m_WICBitmapDecoder);
std::wostringstream output_str; // 用于格式化字符串
switch (hr)
{
case S_OK: break; // 解码成功,直接 break 进入下一步即可
case HRESULT_FROM_WIN32(ERROR_FILE_NOT_FOUND): // 文件找不到
output_str << L"找不到文件 " << TextureFilename << L" !请检查文件路径是否有误!";
MessageBox(NULL, output_str.str().c_str(), L"错误", MB_OK | MB_ICONERROR);
return false;
case HRESULT_FROM_WIN32(ERROR_FILE_CORRUPT): // 文件句柄正在被另一个应用进程占用
output_str << L"文件 " << TextureFilename << L" 已经被另一个应用进程打开并占用了!请先关闭那个应用进程!";
MessageBox(NULL, output_str.str().c_str(), L"错误", MB_OK | MB_ICONERROR);
return false;
case WINCODEC_ERR_COMPONENTNOTFOUND: // 找不到可解码的组件,说明这不是有效的图像文件
output_str << L"文件 " << TextureFilename << L" 不是有效的图像文件,无法解码!请检查文件是否为图像文件!";
MessageBox(NULL, output_str.str().c_str(), L"错误", MB_OK | MB_ICONERROR);
return false;
default: // 发生其他未知错误
output_str << L"文件 " << TextureFilename << L" 解码失败!发生了其他错误,错误码:" << hr << L" ,请查阅微软官方文档。";
MessageBox(NULL, output_str.str().c_str(), L"错误", MB_OK | MB_ICONERROR);
return false;
}
// 获取图片数据的第一帧,这个 GetFrame 可以用于 gif 这种多帧动图
m_WICBitmapDecoder->GetFrame(0, &m_WICBitmapDecodeFrame);
// 获取图片格式,并将它转化为 DX12 能接受的纹理格式
// 如果碰到格式无法支持的错误,可以用微软提供的 画图3D 来转换,强力推荐!
WICPixelFormatGUID SourceFormat = {}; // 源图格式
GUID TargetFormat = {}; // 目标格式
m_WICBitmapDecodeFrame->GetPixelFormat(&SourceFormat); // 获取源图格式
if (DX12TextureHelper::GetTargetPixelFormat(&SourceFormat, &TargetFormat)) // 获取目标格式
{
TextureFormat = DX12TextureHelper::GetDXGIFormatFromPixelFormat(&TargetFormat); // 获取 DX12 支持的格式
}
else // 如果没有可支持的目标格式
{
::MessageBox(NULL, L"此纹理不受支持!", L"提示", MB_OK);
return false;
}
// 获取目标格式后,将纹理转换为目标格式,使其能被 DX12 使用
m_WICFactory->CreateFormatConverter(&m_WICFormatConverter); // 创建图片转换器
// 初始化转换器,实际上是把位图进行了转换
m_WICFormatConverter->Initialize(m_WICBitmapDecodeFrame.Get(), TargetFormat, WICBitmapDitherTypeNone,
nullptr, 0.0f, WICBitmapPaletteTypeCustom);
// 将位图数据继承到 WIC 位图资源,我们要在这个 WIC 位图资源上获取信息
m_WICFormatConverter.As(&m_WICBitmapSource);
m_WICBitmapSource->GetSize(&TextureWidth, &TextureHeight); // 获取纹理宽高
ComPtr<IWICComponentInfo> _temp_WICComponentInfo = {}; // 用于获取 BitsPerPixel 纹理图像深度
ComPtr<IWICPixelFormatInfo> _temp_WICPixelInfo = {}; // 用于获取 BitsPerPixel 纹理图像深度
m_WICFactory->CreateComponentInfo(TargetFormat, &_temp_WICComponentInfo);
_temp_WICComponentInfo.As(&_temp_WICPixelInfo);
_temp_WICPixelInfo->GetBitsPerPixel(&BitsPerPixel); // 获取 BitsPerPixel 图像深度
return true;
}
// 创建 SRV Descriptor Heap 着色器资源描述符堆
void CreateSRVHeap()
{
// 创建 SRV 描述符堆 (Shader Resource View,着色器资源描述符)
D3D12_DESCRIPTOR_HEAP_DESC SRVHeapDesc = {};
SRVHeapDesc.NumDescriptors = 1; // 我们只有一副纹理,只需要用一个 SRV 描述符
SRVHeapDesc.Type = D3D12_DESCRIPTOR_HEAP_TYPE_CBV_SRV_UAV; // 描述符堆类型,CBV、SRV、UAV 这三种描述符可以放在同一种描述符堆上
SRVHeapDesc.Flags = D3D12_DESCRIPTOR_HEAP_FLAG_SHADER_VISIBLE; // 描述符堆标志,Shader-Visible 表示对着色器可见
// 创建 SRV 描述符堆
m_D3D12Device->CreateDescriptorHeap(&SRVHeapDesc, IID_PPV_ARGS(&m_SRVHeap));
}
// 上取整算法,对 A 向上取整,判断至少要多少个长度为 B 的空间才能容纳 A,用于内存对齐
inline UINT Ceil(UINT A, UINT B)
{
return (A + B - 1) / B;
}
// 创建用于上传的 UploadResource 与用于放纹理的 DefaultResource
void CreateUploadAndDefaultResource()
{
// 计算纹理每行数据的真实数据大小 (单位:Byte 字节),因为纹理图片在内存中是线性存储的
// 想获取纹理的真实大小、正确读取纹理数据、上传到 GPU,必须先获取纹理的 BitsPerPixel 图像深度,因为不同位图深度可能不同
// 然后再计算每行像素占用的字节,除以 8 是因为 1 Byte = 8 bits
BytePerRowSize = TextureWidth * BitsPerPixel / 8;
// 纹理的真实大小 (单位:字节)
TextureSize = BytePerRowSize * TextureHeight;
// 上传堆资源每行的大小 (单位:字节),注意这里要进行 256 字节对齐!
// 因为 GPU 与 CPU 架构不同,GPU 注重并行计算,注重结构化数据的快速读取,读取数据都是以 256 字节为一组来读的
// 因此要先要对 BytePerRowSize 进行对齐,判断需要有多少组才能容纳纹理每行像素,不对齐的话数据会读错的。
UploadResourceRowSize = Ceil(BytePerRowSize, 256) * 256;
// 上传堆资源的总大小 (单位:字节),分配空间必须只多不少,否则会报 D3D12 MinimumAlloc Error 资源内存创建错误
// 注意最后一行不用内存对齐 (因为后面没其他行了,不用内存对齐也能正确读取),所以要 (TextureHeight - 1) 再加 BytePerRowSize
UploadResourceSize = UploadResourceRowSize * (TextureHeight - 1) + BytePerRowSize;
// 用于中转纹理的上传堆资源结构体
D3D12_RESOURCE_DESC UploadResourceDesc = {};
UploadResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; // 资源类型,上传堆的资源类型都是 buffer 缓冲
UploadResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; // 资源布局,指定资源的存储方式,上传堆的资源都是 row major 按行线性存储
UploadResourceDesc.Width = UploadResourceSize; // 资源宽度,上传堆的资源宽度是资源的总大小,注意资源大小必须只多不少
UploadResourceDesc.Height = 1; // 资源高度,上传堆仅仅是传递线性资源的,所以高度必须为 1
UploadResourceDesc.Format = DXGI_FORMAT_UNKNOWN; // 资源格式,上传堆资源的格式必须为 UNKNOWN
UploadResourceDesc.DepthOrArraySize = 1; // 资源深度,这个是用于纹理数组和 3D 纹理的,上传堆资源必须为 1
UploadResourceDesc.MipLevels = 1; // Mipmap 等级,这个是用于纹理的,上传堆资源必须为 1
UploadResourceDesc.SampleDesc.Count = 1; // 资源采样次数,上传堆资源都是填 1
// 上传堆属性的结构体,上传堆位于 CPU 和 GPU 的共享内存
D3D12_HEAP_PROPERTIES UploadHeapDesc = { D3D12_HEAP_TYPE_UPLOAD };
// 创建上传堆资源
m_D3D12Device->CreateCommittedResource(&UploadHeapDesc, D3D12_HEAP_FLAG_NONE, &UploadResourceDesc,
D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&m_UploadTextureResource));
// 用于放纹理的默认堆资源结构体
D3D12_RESOURCE_DESC DefaultResourceDesc = {};
DefaultResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_TEXTURE2D; // 资源类型,这里指定为 Texture2D 2D纹理
DefaultResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_UNKNOWN; // 纹理资源的布局都是 UNKNOWN
DefaultResourceDesc.Width = TextureWidth; // 资源宽度,这里填纹理宽度
DefaultResourceDesc.Height = TextureHeight; // 资源高度,这里填纹理高度
DefaultResourceDesc.Format = TextureFormat; // 资源格式,这里填纹理格式,要和纹理一样
DefaultResourceDesc.DepthOrArraySize = 1; // 资源深度,我们只有一副纹理,所以填 1
DefaultResourceDesc.MipLevels = 1; // Mipmap 等级,我们暂时不使用 Mipmap,所以填 1
DefaultResourceDesc.SampleDesc.Count = 1; // 资源采样次数,这里我们填 1 就行
// 默认堆属性的结构体,默认堆位于显存
D3D12_HEAP_PROPERTIES DefaultHeapDesc = { D3D12_HEAP_TYPE_DEFAULT };
// 创建默认堆资源
m_D3D12Device->CreateCommittedResource(&DefaultHeapDesc, D3D12_HEAP_FLAG_NONE, &DefaultResourceDesc,
D3D12_RESOURCE_STATE_COPY_DEST, nullptr, IID_PPV_ARGS(&m_DefaultTextureResource));
}
// 向命令队列发出命令,将纹理数据复制到 DefaultResource
void CopyTextureDataToDefaultResource()
{
// 用于暂时存储纹理数据的指针,这里要用 malloc 分配空间
BYTE* TextureData = (BYTE*)malloc(TextureSize);
// 将整块纹理数据读到 TextureData 中,方便后文的 memcpy 复制操作
m_WICBitmapSource->CopyPixels(nullptr, BytePerRowSize, TextureSize, TextureData);
// 用于传递资源的指针
BYTE* TransferPointer = nullptr;
// Map 开始映射,Map 方法会得到上传堆资源的地址 (在共享内存上),传递给指针,这样我们就能通过 memcpy 操作复制数据了
m_UploadTextureResource->Map(0, nullptr, reinterpret_cast<void**>(&TransferPointer));
// 这里我们要逐行复制数据!注意两个指针偏移的长度不同!
for (UINT i = 0; i < TextureHeight; i++)
{
// 向上传堆资源逐行复制纹理数据 (CPU 高速缓存 -> 共享内存)
memcpy(TransferPointer, TextureData, BytePerRowSize);
// 纹理指针偏移到下一行
TextureData += BytePerRowSize;
// 上传堆资源指针偏移到下一行,注意偏移长度不同!
TransferPointer += UploadResourceRowSize;
}
// Unmap 结束映射,因为我们无法直接读写默认堆资源,需要上传堆复制到那里,在复制之前,我们需要先结束映射,让上传堆处于只读状态
m_UploadTextureResource->Unmap(0, nullptr);
TextureData -= TextureSize; // 纹理资源指针偏移回初始位置
free(TextureData); // 释放上文 malloc 分配的空间,后面我们用不到它,不要让它占内存
D3D12_PLACED_SUBRESOURCE_FOOTPRINT PlacedFootprint = {}; // 资源脚本,用来描述要复制的资源
D3D12_RESOURCE_DESC DefaultResourceDesc = m_DefaultTextureResource->GetDesc(); // 默认堆资源结构体
// 获取纹理复制脚本,用于下文的纹理复制
m_D3D12Device->GetCopyableFootprints(&DefaultResourceDesc, 0, 1, 0, &PlacedFootprint, nullptr, nullptr, nullptr);
D3D12_TEXTURE_COPY_LOCATION DstLocation = {}; // 复制目标位置 (默认堆资源) 结构体
DstLocation.Type = D3D12_TEXTURE_COPY_TYPE_SUBRESOURCE_INDEX; // 纹理复制类型,这里必须指向纹理
DstLocation.SubresourceIndex = 0; // 指定要复制的子资源索引
DstLocation.pResource = m_DefaultTextureResource.Get(); // 要复制到的资源
D3D12_TEXTURE_COPY_LOCATION SrcLocation = {}; // 复制源位置 (上传堆资源) 结构体
SrcLocation.Type = D3D12_TEXTURE_COPY_TYPE_PLACED_FOOTPRINT; // 纹理复制类型,这里必须指向缓冲区
SrcLocation.PlacedFootprint = PlacedFootprint; // 指定要复制的资源脚本信息
SrcLocation.pResource = m_UploadTextureResource.Get(); // 被复制数据的缓冲
// 复制资源需要使用 GPU 的 CopyEngine 复制引擎,所以需要向命令队列发出复制命令
m_CommandAllocator->Reset(); // 先重置命令分配器
m_CommandList->Reset(m_CommandAllocator.Get(), nullptr); // 再重置命令列表,复制命令不需要 PSO 状态,所以第二个参数填 nullptr
// 记录复制资源到默认堆的命令 (共享内存 -> 显存)
m_CommandList->CopyTextureRegion(&DstLocation, 0, 0, 0, &SrcLocation, nullptr);
// 关闭命令列表
m_CommandList->Close();
// 用于传递命令用的临时 ID3D12CommandList 数组
ID3D12CommandList* _temp_cmdlists[] = { m_CommandList.Get() };
// 提交复制命令!GPU 开始复制!
m_CommandQueue->ExecuteCommandLists(1, _temp_cmdlists);
// 将围栏预定值设定为下一帧,注意复制资源也需要围栏等待,否则会发生资源冲突
FenceValue++;
// 在命令队列 (命令队列在 GPU 端) 设置围栏预定值,此命令会加入到命令队列中
// 命令队列执行到这里会修改围栏值,表示复制已完成,"击中"围栏
m_CommandQueue->Signal(m_Fence.Get(), FenceValue);
// 设置围栏的预定事件,当复制完成时,围栏被"击中",激发预定事件,将事件由无信号状态转换成有信号状态
m_Fence->SetEventOnCompletion(FenceValue, RenderEvent);
}
// 最终创建 SRV 着色器资源描述符,用于描述 DefaultResource 为一块纹理
void CreateSRV()
{
// SRV 描述符信息结构体
D3D12_SHADER_RESOURCE_VIEW_DESC SRVDescriptorDesc = {};
// SRV 描述符类型,这里我们指定 Texture2D 2D纹理
SRVDescriptorDesc.ViewDimension = D3D12_SRV_DIMENSION_TEXTURE2D;
// SRV 描述符的格式也要填纹理格式
SRVDescriptorDesc.Format = TextureFormat;
// 纹理采样后每个纹理像素 RGBA 分量的顺序,D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING 表示纹理采样后分量顺序不改变
SRVDescriptorDesc.Shader4ComponentMapping = D3D12_DEFAULT_SHADER_4_COMPONENT_MAPPING;
// 这里我们不使用 Mipmap,所以填 1
SRVDescriptorDesc.Texture2D.MipLevels = 1;
// 获取 SRV 描述符的 CPU 映射句柄,用于创建资源
SRV_CPUHandle = m_SRVHeap->GetCPUDescriptorHandleForHeapStart();
// 创建 SRV 描述符
m_D3D12Device->CreateShaderResourceView(m_DefaultTextureResource.Get(), &SRVDescriptorDesc, SRV_CPUHandle);
// 获取 SRV 描述符的 GPU 映射句柄,用于命令列表设置 SRVHeap 描述符堆,着色器引用 SRV 描述符找纹理资源
SRV_GPUHandle = m_SRVHeap->GetGPUDescriptorHandleForHeapStart();
}
// 创建 Constant Buffer Resource 常量缓冲资源,常量缓冲是一块预先分配的高速显存,用于存储每一帧都要变换的资源,这里我们要存储 MVP 矩阵
void CreateCBVResource()
{
// 常量资源宽度,这里填整个结构体的大小。注意!硬件要求,常量缓冲需要 256 字节对齐!所以这里要进行 Ceil 向上取整,进行内存对齐!
UINT CBufferWidth = Ceil(sizeof(CBuffer), 256) * 256;
D3D12_RESOURCE_DESC CBVResourceDesc = {}; // 常量缓冲资源信息结构体
CBVResourceDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; // 上传堆资源都是缓冲
CBVResourceDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; // 上传堆资源都是按行存储数据的 (一维线性存储)
CBVResourceDesc.Width = CBufferWidth; // 常量缓冲区资源宽度 (要分配显存的总大小)
CBVResourceDesc.Height = 1; // 上传堆资源都是存储一维线性资源,所以高度必须为 1
CBVResourceDesc.Format = DXGI_FORMAT_UNKNOWN; // 上传堆资源的格式必须为 DXGI_FORMAT_UNKNOWN
CBVResourceDesc.DepthOrArraySize = 1; // 资源深度,这个是用于纹理数组和 3D 纹理的,上传堆资源必须为 1
CBVResourceDesc.MipLevels = 1; // Mipmap 等级,这个是用于纹理的,上传堆资源必须为 1
CBVResourceDesc.SampleDesc.Count = 1; // 资源采样次数,上传堆资源都是填 1
// 上传堆属性的结构体,上传堆位于 CPU 和 GPU 的共享内存
D3D12_HEAP_PROPERTIES UploadHeapDesc = { D3D12_HEAP_TYPE_UPLOAD };
// 创建常量缓冲资源
m_D3D12Device->CreateCommittedResource(&UploadHeapDesc, D3D12_HEAP_FLAG_NONE, &CBVResourceDesc,
D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&m_CBVResource));
// 常量缓冲直接 Map 映射到结构体指针就行即可,无需 Unmap 关闭映射,Map-Unmap 是耗时操作,对于动态数据我们都只需要 Map 一次就行,然后直接对指针修改数据,这样就实现了常量缓冲数据的修改
// 因为我们每帧都要变换 MVP 矩阵,每帧都要对 MVPBuffer 进行修改,所以我们直接将上传堆资源地址映射到结构体指针
// 每帧直接对指针进行修改操作,不用再进行 Unmap 了,这样着色器每帧都能读取到修改后的数据了
m_CBVResource->Map(0, nullptr, reinterpret_cast<void**>(&MVPBuffer));
}
// 创建根签名
void CreateRootSignature()
{
ComPtr<ID3DBlob> SignatureBlob; // 根签名字节码
ComPtr<ID3DBlob> ErrorBlob; // 错误字节码,根签名创建失败时用 OutputDebugStringA((const char*)ErrorBlob->GetBufferPointer()); 可以获取报错信息
D3D12_ROOT_PARAMETER RootParameters[2] = {}; // 根参数数组
// 第一个根参数:根描述表 (Range: SRV)
D3D12_DESCRIPTOR_RANGE SRVDescriptorRangeDesc = {}; // Range 描述符范围结构体,一块 Range 表示一堆连续的同类型描述符
SRVDescriptorRangeDesc.RangeType = D3D12_DESCRIPTOR_RANGE_TYPE_SRV; // Range 类型,这里指定 SRV 类型,CBV_SRV_UAV 在这里分流
SRVDescriptorRangeDesc.NumDescriptors = 1; // Range 里面的描述符数量 N,一次可以绑定多个描述符到多个寄存器槽上
SRVDescriptorRangeDesc.BaseShaderRegister = 0; // Range 要绑定的起始寄存器槽编号 i,绑定范围是 [s(i),s(i+N)],我们绑定 s0
SRVDescriptorRangeDesc.RegisterSpace = 0; // Range 要绑定的寄存器空间,整个 Range 都会绑定到同一寄存器空间上,我们绑定 space0
SRVDescriptorRangeDesc.OffsetInDescriptorsFromTableStart = 0; // Range 到根描述表开头的偏移量 (单位:描述符),根签名需要用它来寻找 Range 的地址,我们这填 0 就行
D3D12_ROOT_DESCRIPTOR_TABLE RootDescriptorTableDesc = {}; // RootDescriptorTable 根描述表信息结构体,一个 Table 可以有多个 Range
RootDescriptorTableDesc.pDescriptorRanges = &SRVDescriptorRangeDesc; // Range 描述符范围指针
RootDescriptorTableDesc.NumDescriptorRanges = 1; // 根描述表中 Range 的数量
RootParameters[0].ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; // 根参数在着色器中的可见性,这里指定仅在像素着色器可见 (只有像素着色器用到了纹理)
RootParameters[0].ParameterType = D3D12_ROOT_PARAMETER_TYPE_DESCRIPTOR_TABLE; // 根参数类型,这里我们选 Table 根描述表,一个根描述表占用 1 DWORD
RootParameters[0].DescriptorTable = RootDescriptorTableDesc; // 根参数指针
// 第二个根参数:CBV 根描述符,根描述符是内联描述符,所以下文绑定根参数时,只需要传递常量缓冲资源的地址即可
D3D12_ROOT_DESCRIPTOR CBVRootDescriptorDesc = {}; // 常量缓冲根描述符信息结构体
CBVRootDescriptorDesc.ShaderRegister = 0; // 要绑定的寄存器编号,这里对应 HLSL 的 b0 寄存器
CBVRootDescriptorDesc.RegisterSpace = 0; // 要绑定的命名空间,这里对应 HLSL 的 space0
RootParameters[1].ShaderVisibility = D3D12_SHADER_VISIBILITY_ALL; // 常量缓冲对整个渲染管线都可见
RootParameters[1].ParameterType = D3D12_ROOT_PARAMETER_TYPE_CBV; // 第二个根参数的类型:CBV 根描述符
RootParameters[1].Descriptor = CBVRootDescriptorDesc; // 填上文的结构体
D3D12_STATIC_SAMPLER_DESC StaticSamplerDesc = {}; // 静态采样器结构体,静态采样器不会占用根签名
StaticSamplerDesc.ShaderRegister = 0; // 要绑定的寄存器槽,对应 s0
StaticSamplerDesc.RegisterSpace = 0; // 要绑定的寄存器空间,对应 space0
StaticSamplerDesc.ShaderVisibility = D3D12_SHADER_VISIBILITY_PIXEL; // 静态采样器在着色器中的可见性,这里指定仅在像素着色器可见 (只有像素着色器用到了纹理采样)
StaticSamplerDesc.Filter = D3D12_FILTER_COMPARISON_MIN_MAG_MIP_POINT; // 纹理过滤类型,这里我们直接选 邻近点采样 就行
StaticSamplerDesc.AddressU = D3D12_TEXTURE_ADDRESS_MODE_BORDER; // 在 U 方向上的纹理寻址方式
StaticSamplerDesc.AddressV = D3D12_TEXTURE_ADDRESS_MODE_BORDER; // 在 V 方向上的纹理寻址方式
StaticSamplerDesc.AddressW = D3D12_TEXTURE_ADDRESS_MODE_BORDER; // 在 W 方向上的纹理寻址方式 (3D 纹理会用到)
StaticSamplerDesc.MinLOD = 0; // 最小 LOD 细节层次,这里我们默认填 0 就行
StaticSamplerDesc.MaxLOD = D3D12_FLOAT32_MAX; // 最大 LOD 细节层次,这里我们默认填 D3D12_FLOAT32_MAX (没有 LOD 上限)
StaticSamplerDesc.MipLODBias = 0; // 基础 Mipmap 采样偏移量,我们这里我们直接填 0 就行
StaticSamplerDesc.MaxAnisotropy = 1; // 各向异性过滤等级,我们不使用各向异性过滤,需要默认填 1
StaticSamplerDesc.ComparisonFunc = D3D12_COMPARISON_FUNC_NEVER; // 这个是用于阴影贴图的,我们不需要用它,所以填 D3D12_COMPARISON_FUNC_NEVER
D3D12_ROOT_SIGNATURE_DESC rootsignatureDesc = {}; // 根签名信息结构体,上限 64 DWORD,静态采样器不占用根签名
rootsignatureDesc.NumParameters = 2; // 根参数数量
rootsignatureDesc.pParameters = RootParameters; // 根参数指针
rootsignatureDesc.NumStaticSamplers = 1; // 静态采样器数量
rootsignatureDesc.pStaticSamplers = &StaticSamplerDesc; // 静态采样器指针
// 根签名标志,可以设置渲染管线不同阶段下的输入参数状态。注意这里!我们要从 IA 阶段输入顶点数据,所以要通过根签名,设置渲染管线允许从 IA 阶段读入数据
rootsignatureDesc.Flags = D3D12_ROOT_SIGNATURE_FLAG_ALLOW_INPUT_ASSEMBLER_INPUT_LAYOUT;
// 编译根签名,让根签名先编译成 GPU 可读的二进制字节码
D3D12SerializeRootSignature(&rootsignatureDesc, D3D_ROOT_SIGNATURE_VERSION_1_0, &SignatureBlob, &ErrorBlob);
if (ErrorBlob) // 如果根签名编译出错,ErrorBlob 可以提供报错信息
{
OutputDebugStringA((const char*)ErrorBlob->GetBufferPointer());
OutputDebugStringA("\n");
}
// 用这个二进制字节码创建根签名对象
m_D3D12Device->CreateRootSignature(0, SignatureBlob->GetBufferPointer(), SignatureBlob->GetBufferSize(), IID_PPV_ARGS(&m_RootSignature));
}
// 创建渲染管线状态对象 (Pipeline State Object, PSO)
void CreatePSO()
{
// PSO 信息结构体
D3D12_GRAPHICS_PIPELINE_STATE_DESC PSODesc = {};
// Input Assembler 输入装配阶段
D3D12_INPUT_LAYOUT_DESC InputLayoutDesc = {}; // 输入样式信息结构体
D3D12_INPUT_ELEMENT_DESC InputElementDesc[2] = {}; // 输入元素信息结构体数组
InputElementDesc[0].SemanticName = "POSITION"; // 要锚定的语义
InputElementDesc[0].SemanticIndex = 0; // 语义索引,目前我们填 0 就行
InputElementDesc[0].Format = DXGI_FORMAT_R32G32B32A32_FLOAT; // 输入格式
InputElementDesc[0].InputSlot = 0; // 输入槽编号,目前我们填 0 就行
InputElementDesc[0].AlignedByteOffset = 0; // 在输入槽中的偏移
// 输入流类型,一种是我们现在用的 D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA 逐顶点输入流,还有一种叫逐实例输入流,后面再学
InputElementDesc[0].InputSlotClass = D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA;
InputElementDesc[0].InstanceDataStepRate = 0; // 实例数据步进率,目前我们没有用到实例化,填 0
InputElementDesc[1].SemanticName = "TEXCOORD"; // 要锚定的语义
InputElementDesc[1].SemanticIndex = 0; // 语义索引
InputElementDesc[1].Format = DXGI_FORMAT_R32G32_FLOAT; // 输入格式
InputElementDesc[1].InputSlot = 0; // 输入槽编号
// 在输入槽中的偏移,因为 position 与 texcoord 在同一输入槽(0号输入槽)
// position 是 float4,有 4 个 float ,每个 float 占 4 个字节,所以要偏移 4*4=16 个字节,这样才能确定 texcoord 参数的位置,不然装配的时候会覆盖原先 position 的数据
InputElementDesc[1].AlignedByteOffset = 16; // 在输入槽中的偏移
InputElementDesc[1].InputSlotClass = D3D12_INPUT_CLASSIFICATION_PER_VERTEX_DATA; // 输入流类型
InputElementDesc[1].InstanceDataStepRate = 0; // 实例数据步进率
InputLayoutDesc.NumElements = 2; // 输入元素个数
InputLayoutDesc.pInputElementDescs = InputElementDesc; // 输入元素结构体数组指针
PSODesc.InputLayout = InputLayoutDesc; // 设置渲染管线 IA 阶段的输入样式
ComPtr<ID3DBlob> VertexShaderBlob; // 顶点着色器二进制字节码
ComPtr<ID3DBlob> PixelShaderBlob; // 像素着色器二进制字节码
ComPtr<ID3DBlob> ErrorBlob; // 错误字节码,根签名创建失败时用 OutputDebugStringA((const char*)ErrorBlob->GetBufferPointer()); 可以获取报错信息
// 编译顶点着色器 Vertex Shader
D3DCompileFromFile(L"shader.hlsl", nullptr, nullptr, "VSMain", "vs_5_1", NULL, NULL, &VertexShaderBlob, &ErrorBlob);
if (ErrorBlob) // 如果着色器编译出错,ErrorBlob 可以提供报错信息
{
OutputDebugStringA((const char*)ErrorBlob->GetBufferPointer());
OutputDebugStringA("\n");
}
// 编译像素着色器 Pixel Shader
D3DCompileFromFile(L"shader.hlsl", nullptr, nullptr, "PSMain", "ps_5_1", NULL, NULL, &PixelShaderBlob, &ErrorBlob);
if (ErrorBlob) // 如果着色器编译出错,ErrorBlob 可以提供报错信息
{
OutputDebugStringA((const char*)ErrorBlob->GetBufferPointer());
OutputDebugStringA("\n");
}
PSODesc.VS.pShaderBytecode = VertexShaderBlob->GetBufferPointer(); // VS 字节码数据指针
PSODesc.VS.BytecodeLength = VertexShaderBlob->GetBufferSize(); // VS 字节码数据长度
PSODesc.PS.pShaderBytecode = PixelShaderBlob->GetBufferPointer(); // PS 字节码数据指针
PSODesc.PS.BytecodeLength = PixelShaderBlob->GetBufferSize(); // PS 字节码数据长度
// Rasterizer 光栅化
PSODesc.RasterizerState.CullMode = D3D12_CULL_MODE_BACK; // 剔除模式,指定是否开启背面/正面/不剔除,这里选背面剔除
PSODesc.RasterizerState.FillMode = D3D12_FILL_MODE_SOLID; // 填充模式,指定是否开启纯色/线框填充,这里选纯色填充
// 第一次设置根签名!本次设置是将根签名与 PSO 绑定,设置渲染管线的输入参数状态
PSODesc.pRootSignature = m_RootSignature.Get();
// 设置基本图元,这里我们设置三角形面
PSODesc.PrimitiveTopologyType = D3D12_PRIMITIVE_TOPOLOGY_TYPE_TRIANGLE;
// 设置渲染目标数量,我们只有一副渲染目标 (颜色缓冲) 需要进行渲染,所以填 1
PSODesc.NumRenderTargets = 1;
// 设置渲染目标的格式,这里要和交换链指定窗口缓冲的格式一致,这里的 0 指的是渲染目标的索引
PSODesc.RTVFormats[0] = DXGI_FORMAT_R8G8B8A8_UNORM;
// 设置混合阶段 (输出合并阶段) 下 RGBA 颜色通道的开启和关闭,D3D12_COLOR_WRITE_ENABLE_ALL 表示 RGBA 四色通道全部开启
PSODesc.BlendState.RenderTarget[0].RenderTargetWriteMask = D3D12_COLOR_WRITE_ENABLE_ALL;
// 设置采样次数,我们这里填 1 就行
PSODesc.SampleDesc.Count = 1;
// 设置采样掩码,这个是用于多重采样的,我们直接填全采样 (UINT_MAX,就是将 UINT 所有的比特位全部填充为 1) 就行
PSODesc.SampleMask = UINT_MAX;
// 最终创建 PSO 对象
m_D3D12Device->CreateGraphicsPipelineState(&PSODesc, IID_PPV_ARGS(&m_PipelineStateObject));
}
// 创建顶点资源
void CreateVertexResource()
{
// CPU 高速缓存上的顶点信息数组,注意 DirectX 使用的是左手坐标系,写顶点信息时,请比一比你的左手!
VERTEX vertexs[24] =
{
// 正面
{{0,2,0,1},{0,0}},
{{2,2,0,1},{1,0}},
{{2,0,0,1},{1,1}},
{{0,0,0,1},{0,1}},
// 背面
{{2,2,2,1},{0,0}},
{{0,2,2,1},{1,0}},
{{0,0,2,1},{1,1}},
{{2,0,2,1},{0,1}},
// 左面
{{0,2,2,1},{0,0}},
{{0,2,0,1},{1,0}},
{{0,0,0,1},{1,1}},
{{0,0,2,1},{0,1}},
// 右面
{{2,2,0,1},{0,0}},
{{2,2,2,1},{1,0}},
{{2,0,2,1},{1,1}},
{{2,0,0,1},{0,1}},
// 上面
{{0,2,2,1},{0,0}},
{{2,2,2,1},{1,0}},
{{2,2,0,1},{1,1}},
{{0,2,0,1},{0,1}},
// 下面
{{0,0,0,1},{0,0}},
{{2,0,0,1},{1,0}},
{{2,0,2,1},{1,1}},
{{0,0,2,1},{0,1}}
};
D3D12_RESOURCE_DESC VertexDesc = {}; // D3D12Resource 信息结构体
VertexDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; // 资源类型,上传堆的资源类型都是 buffer 缓冲
VertexDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; // 资源布局,指定资源的存储方式,上传堆的资源都是 row major 按行线性存储
VertexDesc.Width = sizeof(vertexs); // 资源宽度,上传堆的资源宽度是资源的总大小
VertexDesc.Height = 1; // 资源高度,上传堆仅仅是传递线性资源的,所以高度必须为 1
VertexDesc.Format = DXGI_FORMAT_UNKNOWN; // 资源格式,上传堆资源的格式必须为 UNKNOWN
VertexDesc.DepthOrArraySize = 1; // 资源深度,这个是用于纹理数组和 3D 纹理的,上传堆资源必须为 1
VertexDesc.MipLevels = 1; // Mipmap 等级,这个是用于纹理的,上传堆资源必须为 1
VertexDesc.SampleDesc.Count = 1; // 资源采样次数,上传堆资源都是填 1
// 上传堆属性的结构体,上传堆位于 CPU 和 GPU 的共享内存
D3D12_HEAP_PROPERTIES UploadHeapDesc = { D3D12_HEAP_TYPE_UPLOAD };
// 创建资源,CreateCommittedResource 会为资源自动创建一个等大小的隐式堆,这个隐式堆的所有权由操作系统管理,开发者不可控制
m_D3D12Device->CreateCommittedResource(&UploadHeapDesc, D3D12_HEAP_FLAG_NONE,
&VertexDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&m_VertexResource));
// 用于传递资源的指针
BYTE* TransferPointer = nullptr;
// Map 开始映射,Map 方法会得到这个 D3D12Resource 的地址 (在共享内存上),传递给指针,这样我们就能通过 memcpy 操作复制数据了
m_VertexResource->Map(0, nullptr, reinterpret_cast<void**>(&TransferPointer));
// 将 CPU 高速缓存上的顶点数据 复制到 共享内存上的 D3D12Resource ,CPU 高速缓存 -> 共享内存
memcpy(TransferPointer, vertexs, sizeof(vertexs));
// Unmap 结束映射,D3D12Resource 变成只读状态,这样做能加速 GPU 的访问
m_VertexResource->Unmap(0, nullptr);
// 填写 VertexBufferView VBV 顶点缓冲描述符,描述上面的 D3D12Resource,让 GPU 知道这是一个顶点缓冲
VertexBufferView.BufferLocation = m_VertexResource->GetGPUVirtualAddress(); // 顶点缓冲资源的地址
VertexBufferView.SizeInBytes = sizeof(vertexs); // 整个顶点缓冲的总大小
VertexBufferView.StrideInBytes = sizeof(VERTEX); // 每个顶点元素的大小 (步长)
}
// 创建索引资源,顶点索引可以重复使用顶点资源,减少要传递的顶点数量,节省显存
void CreateIndexResource()
{
// 顶点索引数组,注意这里的 UINT == UINT32,后面填的格式 (步长) 必须是 DXGI_FORMAT_R32_UINT,否则会出错
UINT IndexArray[36] =
{
// 正面
0,1,2,0,2,3,
// 背面
4,5,6,4,6,7,
// 左面
8,9,10,8,10,11,
// 右面
12,13,14,12,14,15,
// 上面
16,17,18,16,18,19,
// 下面
20,21,22,20,22,23
};
D3D12_RESOURCE_DESC IndexResDesc = {}; // D3D12Resource 信息结构体
IndexResDesc.Dimension = D3D12_RESOURCE_DIMENSION_BUFFER; // 资源类型,上传堆的资源类型都是 buffer 缓冲
IndexResDesc.Layout = D3D12_TEXTURE_LAYOUT_ROW_MAJOR; // 资源布局,指定资源的存储方式,上传堆的资源都是 row major 按行线性存储
IndexResDesc.Width = sizeof(IndexArray); // 资源宽度,上传堆的资源宽度是资源的总大小
IndexResDesc.Height = 1; // 资源高度,上传堆仅仅是传递线性资源的,所以高度必须为 1
IndexResDesc.Format = DXGI_FORMAT_UNKNOWN; // 资源格式,上传堆资源的格式必须为 UNKNOWN
IndexResDesc.DepthOrArraySize = 1; // 资源深度,这个是用于纹理数组和 3D 纹理的,上传堆资源必须为 1
IndexResDesc.MipLevels = 1; // Mipmap 等级,这个是用于纹理的,上传堆资源必须为 1
IndexResDesc.SampleDesc.Count = 1; // 资源采样次数,上传堆资源都是填 1
// 上传堆属性的结构体,上传堆位于 CPU 和 GPU 的共享内存
D3D12_HEAP_PROPERTIES UploadHeapDesc = { D3D12_HEAP_TYPE_UPLOAD };
// 创建资源,CreateCommittedResource 会为资源自动创建一个等大小的隐式堆,这个隐式堆的所有权由操作系统管理,开发者不可控制
m_D3D12Device->CreateCommittedResource(&UploadHeapDesc, D3D12_HEAP_FLAG_NONE,
&IndexResDesc, D3D12_RESOURCE_STATE_GENERIC_READ, nullptr, IID_PPV_ARGS(&m_IndexResource));
// 用于传递资源的指针
BYTE* TransferPointer = nullptr;
// Map 开始映射,Map 方法会得到这个 D3D12Resource 的地址 (在共享内存上),传递给指针,这样我们就能通过 memcpy 操作复制数据了
m_IndexResource->Map(0, nullptr, reinterpret_cast<void**>(&TransferPointer));
// 将 CPU 高速缓存上的索引数据 复制到 共享内存上的 D3D12Resource ,CPU 高速缓存 -> 共享内存
memcpy(TransferPointer, IndexArray, sizeof(IndexArray));
// Unmap 结束映射,D3D12Resource 变成只读状态,这样做能加速 GPU 的访问
m_IndexResource->Unmap(0, nullptr);
// 填写 IndexBufferView IBV 索引缓冲描述符,描述上面的 D3D12Resource,让 GPU 知道这是一个索引缓冲
IndexBufferView.BufferLocation = m_IndexResource->GetGPUVirtualAddress(); // 索引缓冲资源的地址
IndexBufferView.SizeInBytes = sizeof(IndexArray); // 整个索引缓冲的总大小
IndexBufferView.Format = DXGI_FORMAT_R32_UINT; // 每个索引的大小 (步长),不同类型的数据,索引的格式是固定的
}
// 更新常量缓冲区,将每帧新的 MVP 矩阵传递到常量缓冲区中,这样就能看到动态的 3D 画面了
void UpdateConstantBuffer()
{
// 更新模型矩阵,这里我们让模型旋转 30° 就行
ModelMatrix = XMMatrixRotationY(30.0f);
// 更新观察矩阵,注意前两个参数是点,第三个参数才是向量
ViewMatrix = XMMatrixLookAtLH(EyePosition, FocusPosition, UpDirection);
// 更新投影矩阵 (垂直视场角是 pi/4,投影窗口宽高比是 4:3,近平面距离是 0.1,远平面距离是 1000,注意近平面和远平面距离不能为 0!)
ProjectionMatrix = XMMatrixPerspectiveFovLH(XM_PIDIV4, 4.0 / 3, 0.1, 1000);
// 将更新后的矩阵,存储到共享内存上的常量缓冲,这样 GPU 就可以访问到 MVP 矩阵了
XMStoreFloat4x4(&MVPBuffer->MVPMatrix, ModelMatrix * ViewMatrix * ProjectionMatrix);
}
// 渲染
void Render()
{
// 每帧渲染开始前,调用 UpdateConstantBuffer() 更新常量缓冲区
UpdateConstantBuffer();
// 获取 RTV 堆首句柄
RTVHandle = m_RTVHeap->GetCPUDescriptorHandleForHeapStart();
// 获取当前渲染的后台缓冲序号
FrameIndex = m_DXGISwapChain->GetCurrentBackBufferIndex();
// 偏移 RTV 句柄,找到对应的 RTV 描述符
RTVHandle.ptr += FrameIndex * RTVDescriptorSize;
// 先重置命令分配器
m_CommandAllocator->Reset();
// 再重置命令列表,Close 关闭状态 -> Record 录制状态
m_CommandList->Reset(m_CommandAllocator.Get(), nullptr);
// 将起始转换屏障的资源指定为当前渲染目标
beg_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
// 调用资源屏障,将渲染目标由 Present 呈现(只读) 转换到 RenderTarget 渲染目标(只写)
m_CommandList->ResourceBarrier(1, &beg_barrier);
// 第二次设置根签名!本次设置将会检查 渲染管线绑定的根签名 与 这里的根签名 是否匹配
// 以及根签名指定的资源是否被正确绑定,检查完毕后会进行简单的映射
m_CommandList->SetGraphicsRootSignature(m_RootSignature.Get());
// 设置渲染管线状态,可以在上面 m_CommandList->Reset() 的时候直接在第二个参数设置 PSO
m_CommandList->SetPipelineState(m_PipelineStateObject.Get());
// 设置视口 (光栅化阶段),用于光栅化里的屏幕映射
m_CommandList->RSSetViewports(1, &viewPort);
// 设置裁剪矩形 (光栅化阶段)
m_CommandList->RSSetScissorRects(1, &ScissorRect);
// 用 RTV 句柄设置渲染目标
m_CommandList->OMSetRenderTargets(1, &RTVHandle, false, nullptr);
// 清空当前渲染目标的背景为天蓝色
m_CommandList->ClearRenderTargetView(RTVHandle, DirectX::Colors::SkyBlue, 0, nullptr);
// 用于设置描述符堆用的临时 ID3D12DescriptorHeap 数组
ID3D12DescriptorHeap* _temp_DescriptorHeaps[] = { m_SRVHeap.Get() };
// 设置描述符堆
m_CommandList->SetDescriptorHeaps(1, _temp_DescriptorHeaps);
// 设置 SRV 句柄 (第一个根参数)
m_CommandList->SetGraphicsRootDescriptorTable(0, SRV_GPUHandle);
// 设置常量缓冲 (第二个根参数),我们复制完数据到 CBVResource 后,就可以让着色器读取、对顶点进行 MVP 变换了
m_CommandList->SetGraphicsRootConstantBufferView(1, m_CBVResource->GetGPUVirtualAddress());
// 设置图元拓扑 (输入装配阶段),我们这里设置三角形列表
m_CommandList->IASetPrimitiveTopology(D3D_PRIMITIVE_TOPOLOGY_TRIANGLELIST);
// 设置 VBV 顶点缓冲描述符 (输入装配阶段)
m_CommandList->IASetVertexBuffers(0, 1, &VertexBufferView);
// 设置 IBV 索引缓冲描述符 (输入装配阶段)
m_CommandList->IASetIndexBuffer(&IndexBufferView);
// Draw Call! 绘制方块
m_CommandList->DrawIndexedInstanced(36, 1, 0, 0, 0);
// 将终止转换屏障的资源指定为当前渲染目标
end_barrier.Transition.pResource = m_RenderTarget[FrameIndex].Get();
// 再通过一次资源屏障,将渲染目标由 RenderTarget 渲染目标(只写) 转换到 Present 呈现(只读)
m_CommandList->ResourceBarrier(1, &end_barrier);
// 关闭命令列表,Record 录制状态 -> Close 关闭状态,命令列表只有关闭才可以提交
m_CommandList->Close();
// 用于传递命令用的临时 ID3D12CommandList 数组
ID3D12CommandList* _temp_cmdlists[] = { m_CommandList.Get() };
// 执行上文的渲染命令!
m_CommandQueue->ExecuteCommandLists(1, _temp_cmdlists);
// 向命令队列发出交换缓冲的命令,此命令会加入到命令队列中,命令队列执行到该命令时,会通知交换链交换缓冲
m_DXGISwapChain->Present(1, NULL);
// 将围栏预定值设定为下一帧
FenceValue++;
// 在命令队列 (命令队列在 GPU 端) 设置围栏预定值,此命令会加入到命令队列中
// 命令队列执行到这里会修改围栏值,表示渲染已完成,"击中"围栏
m_CommandQueue->Signal(m_Fence.Get(), FenceValue);
// 设置围栏的预定事件,当渲染完成时,围栏被"击中",激发预定事件,将事件由无信号状态转换成有信号状态
m_Fence->SetEventOnCompletion(FenceValue, RenderEvent);
}
// 渲染循环
void RenderLoop()
{
bool isExit = false; // 是否退出
MSG msg = {}; // 消息结构体
while (isExit != true)
{
// MsgWaitForMultipleObjects 用于多个线程的无阻塞等待,返回值是激发事件 (线程) 的 ID
// 经过该函数后 RenderEvent 也会自动重置为无信号状态,因为我们创建事件的时候指定了第二个参数为 false
DWORD ActiveEvent = ::MsgWaitForMultipleObjects(1, &RenderEvent, false, INFINITE, QS_ALLINPUT);
switch (ActiveEvent - WAIT_OBJECT_0)
{
case 0: // ActiveEvent 是 0,说明渲染事件已经完成了,进行下一次渲染
Render();
break;
case 1: // ActiveEvent 是 1,说明渲染事件未完成,CPU 主线程同时处理窗口消息,防止界面假死
// 查看消息队列是否有消息,如果有就获取。 PM_REMOVE 表示获取完消息,就立刻将该消息从消息队列中移除
while (PeekMessage(&msg, NULL, 0, 0, PM_REMOVE))
{
// 如果程序没有收到退出消息,就向操作系统发出派发消息的命令
if (msg.message != WM_QUIT)
{
TranslateMessage(&msg); // 翻译消息,将虚拟按键值转换为对应的 ASCII 码 (后文会讲)
DispatchMessage(&msg); // 派发消息,通知操作系统调用回调函数处理消息
}
else
{
isExit = true; // 收到退出消息,就退出消息循环
}
}
break;
case WAIT_TIMEOUT: // 渲染超时
break;
}
}
}
// 回调函数
static LRESULT CALLBACK CallBackFunc(HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam)
{
// 用 switch 将第二个参数分流,每个 case 分别对应一个窗口消息
switch (msg)
{
case WM_DESTROY: // 窗口被销毁 (当按下右上角 X 关闭窗口时)
PostQuitMessage(0); // 向操作系统发出退出请求 (WM_QUIT),结束消息循环
break;
// 如果接收到其他消息,直接默认返回整个窗口
default: return DefWindowProc(hwnd, msg, wParam, lParam);
}
return 0; // 注意这里!
}
// 运行窗口
static void Run(HINSTANCE hins)
{
DX12Engine engine;
engine.InitWindow(hins);
engine.CreateDebugDevice();
engine.CreateDevice();
engine.CreateCommandComponents();
engine.CreateRenderTarget();
engine.CreateFenceAndBarrier();
engine.LoadTextureFromFile();
engine.CreateSRVHeap();
engine.CreateUploadAndDefaultResource();
engine.CopyTextureDataToDefaultResource();
engine.CreateSRV();
engine.CreateCBVResource();
engine.CreateRootSignature();
engine.CreatePSO();
engine.CreateVertexResource();
engine.CreateIndexResource();
engine.RenderLoop();
}
};
// 主函数
int WINAPI WinMain(HINSTANCE hins, HINSTANCE hPrev, LPSTR cmdLine, int cmdShow)
{
DX12Engine::Run(hins);
}
shader.hlsl
// (5) DrawBlock:用 DirectX 12 画一个钻石原矿方块
struct VSInput // VS 阶段输入顶点数据
{
float4 position : POSITION; // 输入顶点的位置,POSITION 语义对应 C++ 端输入布局中的 POSITION
float2 texcoordUV : TEXCOORD; // 输入顶点的纹理坐标,TEXCOORD 语义对应 C++ 端输入布局中的 TEXCOORD
};
struct VSOutput // VS 阶段输出顶点数据
{
float4 position : SV_Position; // 输出顶点的位置,SV_POSITION 是系统语义,指定顶点坐标已经位于齐次裁剪空间,通知光栅化阶段对顶点进行透视除法和屏幕映射
float2 texcoordUV : TEXCOORD; // 输出顶点纹理坐标时,仍然需要 TEXCOORD 语义
};
// Constant Buffer 常量缓冲,常量缓冲是预先分配的一段高速显存,存放每一帧都要变换的数据,例如我们这里的 MVP 变换矩阵
// 常量缓冲对所有着色器都是只读的,着色器不可以修改常量缓冲里面的内容
cbuffer GlobalData : register(b0, space0) // 常量缓冲,b 表示 buffer 缓冲,b0 表示 0 号 CBV 寄存器,space0 表示使用 b0 的 0 号空间
{
row_major float4x4 MVP; // MVP 矩阵,用于将顶点坐标从模型空间变换到齐次裁剪空间,HLSL 默认按列存储,row_major 表示数据按行存储
}
// Vertex Shader 顶点着色器入口函数 (逐顶点输入),接收来自 IA 阶段输入的顶点数据,处理并返回齐次裁剪空间下的顶点坐标
// 上一阶段:Input Assembler 输入装配阶段
// 下一阶段:Rasterization 光栅化阶段
VSOutput VSMain(VSInput input)
{
VSOutput output;
output.position = mul(input.position, MVP); // 注意这里!顶点坐标需要经过一次 MVP 变换!
output.texcoordUV = input.texcoordUV; // 纹理 UV 不用,照常输出即可
return output;
}
// register(*#,spaceN) *表示资源类型,#表示所用的寄存器编号,spaceN 表示使用的 N 号寄存器空间
Texture2D m_texure : register(t0, space0); // 纹理,t 表示 SRV 着色器资源,t0 表示 0 号 SRV 寄存器,space0 表示使用 t0 的 0 号空间
SamplerState m_sampler : register(s0, space0); // 纹理采样器,s 表示采样器,s0 表示 0 号 sampler 寄存器,space0 表示使用 s0 的 0 号空间
// Pixel Shader 像素着色器入口函数 (逐像素输入),接收来自光栅化阶段经过插值后的每个片元,返回像素颜色
// 上一阶段:Rasterization 光栅化阶段
// 下一阶段:Output Merger 输出合并阶段
float4 PSMain(VSOutput input) : SV_Target // SV_Target 也是系统语义,通知输出合并阶段将 PS 阶段返回的颜色写入到渲染目标(颜色缓冲)上
{
return m_texure.Sample(m_sampler, input.texcoordUV); // 在像素着色器根据光栅化插值得到的 UV 坐标对纹理进行采样
}
也许你会觉得很奇怪,不是说 MVP 矩阵会经常变化吗?怎么这里的 MVP 变都不变一下啊?别急,下一节我们就要让这个 MVP 发生变化。
下一节,我们将要深入 Camera 摄像机 这个概念,学习如何实现各种 3D 游戏中经典的 第一人称视角移动。