前言
說實話,我感覺這是一個大坑,不知道為什么要設計成這樣混亂的形式。
(2020年4月12日更新)過了幾年回頭看這篇博客,連我自己都看的頭發都亂了,又重新理了一遍才把自己說通。。於是這篇博客又被我大改了一遍。
在我用的時候,以row_major矩陣,並且mul函數以向量左乘矩陣的形式來繪制時的確能夠正常顯示,並不會有什么感覺。但是也有人會遇到明明傳的矩陣沒有問題,卻怎么樣都繪制不出的情況;或者使用一遍矩陣,在mul函數用向量左乘的形式卻又可以繪制出來的疑問。因此本文目的就是要掃清這些障礙。
ps. 本問題由淡一抹夕霞提供。
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。
一些線性代數基礎
行主序矩陣與列主序矩陣
首先要了解的是,對於連續內存數據:
行主序矩陣是這樣解釋數據的:
而列主序矩陣是這樣解釋數據的:
顯然,行主序矩陣經過一次轉置后就會變成列主序矩陣
矩陣左乘與右乘
由於矩陣乘法不滿足交換律,則需要區分當前矩陣位於乘號的左邊還是右邊。有時候經常都會聽到左乘和右乘這兩個概念,下面是有關它們的含義:
左乘指的是該矩陣位於乘號的左邊,例如:行向量 左乘 矩陣,即行向量在乘號的左邊
右乘指的是該矩陣位於乘號的右邊,例如:列向量 右乘 矩陣,即列向量在乘號的右邊
ps. 向量也是矩陣
行向量v和矩陣M滿足下面的關系:
C++和HLSL中矩陣的內存布局
在C++的DirectXMath中,無論是XMFLOAT4X4,還是使用函數生成的XMMATRIX,都是采用行主序矩陣的解釋方式。它的數據流如下:
上述數據流傳遞到HLSL后,若是傳遞給cb0的寄存器的前4個向量,那么它內存布局一定如下:
cb0[0].xyzw = (m11, m12, m13, m14);
cb0[1].xyzw = (m21, m22, m23, m24);
cb0[2].xyzw = (m31, m32, m33, m34);
cb0[3].xyzw = (m41, m42, m43, m44);
而在HLSL中,默認的matrix或float4x4采用的是列主序矩陣的解釋形式。
假設在HLSL的cbuffer為:
cbuffer cb : register(b0)
{
(row_major) matrix g_World;
}
如果g_World是matrix或float4x4類型,由於是列主序矩陣,上面的4個寄存器存儲的數據會被看作:
而如果g_World是row_major matrix或row_major float4x4類型,則為行主序矩陣,上面的4個寄存器存儲的數據則依然被視作:
HLSL中的mul函數
微軟的官方文檔是這么描述mul函數的(微軟官方文檔鏈接),這里進行個人翻譯:
使用矩陣數學來進行矩陣x左乘矩陣y的運算,要求矩陣x的列數與矩陣y的行數相等。
如果x是一個向量,那么它將被解釋為行向量。
如果y是一個向量,那么它將被解釋為列向量。
表面上看起來很美滿,很智能,但稍有不慎就要在這里踩大坑了。
dp4指令
dp4是一個匯編指令(微軟官方文檔鏈接),使用方法如下:
dp4 dst, src0, src1
其中 src0和src1是一個向量,計算它們的點乘並將結果傳給dst。
當然這里並不是要教大家怎么寫匯編,而是怎么看。
為了了解mul函數是如何進行向量與矩陣的乘法運算,我們需要探討一下它的匯編實現。這里我所使用的是row_major矩陣。首先是向量作為第一個參數的情況:

可以看到這種運算方式實際上卻是按照向量右乘矩陣的形式進行的運算。
然后是將向量作為第二個參數的情況(僅單純的參數交換):

無論是行向量左乘矩陣,還是列向量右乘矩陣,在匯編層面上都是用dp4的形式進行計算,這是因為對矩陣來說在內存上是以4個行向量的形式存儲的,傳遞一行的寄存器向量比傳遞一列更簡單,適合進行與列向量的運算,並且效率會更高。
然后眼尖的同學會發現,同一條指令,只是改變了順序,指令執行的起始地址就產生了差別(16條指令數目差)。
但是交換兩個參數又會導致運算結果/顯示結果的不同,這時候就要看看矩陣所存的值了。
先看一段HLSL代碼:
cbuffer cb : register(b0)
{
row_major matrix gWorld;
row_major matrix gView;
row_major matrix gProj;
}
struct VertexPosNormalTex
{
float3 PosL : POSITION;
float3 NormalL : NORMAL;
float2 Tex : TEXCOORD;
};
struct VertexPosHWNormalTex
{
float4 PosH : SV_POSITION;
float3 PosW : POSITION; // 在世界中的位置
float3 NormalW : NORMAL; // 法向量在世界中的方向
float2 Tex : TEXCOORD;
};
// 頂點着色器
VertexPosHWNormalTex VS(VertexPosNormalTex pIn)
{
VertexPosHWNormalTex pOut;
row_major matrix viewProj = mul(gView, gProj);
pOut.PosW = mul(float4(pIn.PosL, 1.0f), gWorld).xyz;
pOut.PosH = mul(float4(pOut.PosW, 1.0f), viewProj);
pOut.NormalW = mul(pIn.NormalL, (float3x3) gWorldInvTranspose);
pOut.Tex = pIn.Tex;
return pOut;
}
我們只考慮viewProj的初始化和pOut.PosH的賦值操作。
首先是viewProj經過計算后應該得到的值:

這是向量左乘矩陣開始前四個向量寄存器的值(默認HLSL):

這是向量右乘矩陣開始前時四個向量寄存器的值(將float4(pOut.PosW, 1.0f)和viewProj交換):

也許有人會奇怪,怎么在開始運算前兩邊寄存器存儲的內容會不一樣。我們需要往前觀察上一個語句產生的匯編(默認HLSL):

而將float4(pOut.PosW, 1.0f)和viewProj交換后,則匯編代碼沒有了轉置操作:

嚴格意義上說,00000000到0000001B的指令才是上圖語句的實際執行內容,而0000001C到0000002B的代碼則應是在計算pOut.PosH = mul(float4(pOut.PosW, 1.0f), viewProj);之前所進行的一系列額外操作。
因此無論是行向量還是列向量,在執行完0000001B指令后,行主序矩陣viewProj的內存布局一定為:

如果用行向量左乘該行主序矩陣,由於dp4運算需要按列取出這些寄存器的值,為此需要額外16條指令進行轉置(0000001C到0000002B)。
而如果用列向量右乘該行主序矩陣,則不需要進行轉置,直接取寄存器行向量就可以直接進行dp4運算。
因此,我們可以知道一個行向量左乘行主序矩陣時,為了滿足mul函數使用dp4指令優化運算,需要會預先對原來的矩陣進行轉置。其中r4 r5 r6 r3為viewProj轉置后的矩陣,即將會左乘向量float4(pOut.PosW, 1.0f)。而列向量右乘行主序矩陣則可以避免轉置操作。
同理,如果采用列主序矩陣,行向量左乘列主序矩陣可以避免轉置操作;而列向量右乘行主序矩陣又會產生轉置操作。
故對於dp4來說,最好是能夠對一個行向量和列主序矩陣(取列主序矩陣的行,也就是取一行寄存器向量與行向量做點乘)操作,又或者是對一個行主序矩陣(取行主序矩陣的行與列向量做點乘)和列矩陣操作,這樣都能有效避免轉置。

總結
綜上所述,有三處地方可能會發生轉置:
- C++代碼端的轉置
- HLSL中matrix(float4x4)是列主矩陣時會發生轉置
- mul乘法內部是以列向量右乘矩陣的形式實現的,對於行向量左乘矩陣的情況會發生轉置
經過組合,就一共有四種能夠正常繪制的情況:
- C++代碼端不進行轉置,HLSL中使用
row_major matrix(行主序矩陣),mul函數讓向量放在左邊(行向量),這樣實際運算就是(行向量 X 行主序矩陣) 。這種方法易於理解,但是這樣做dp4運算取矩陣的列很不方便,在HLSL中會產生用於轉置矩陣的大量指令,性能上有損失。 - C++代碼端進行轉置,HLSL中使用
matrix(列主序矩陣) ,mul函數讓向量放在左邊(行向量),這樣就是(行向量 X 列主序矩陣),但C++這邊需要進行一次矩陣轉置,HLSL內部不產生轉置 。這是官方例程所使用的方式,這樣可以使得dp4運算可以直接取列主序矩陣的行,從而避免內部產生大量的轉置指令。后續我會將教程的項目也使用這種方式。 - C++代碼端不進行轉置,HLSL中使用
matrix(列主序矩陣),mul函數讓向量放在右邊(列向量),實際運算是(列主序矩陣 X 列向量)。這種方法的確可行,取列矩陣的行也比較方便,效率上又和2等同,就是HLSL那邊的矩陣乘法都要反過來寫,然而DX本身就是崇尚行主矩陣的,把OpenGL的習慣帶來這邊有點。。。 - C++代碼端進行轉置,HLSL中使用
row_major matrix(行主序矩陣),mul函數讓向量放在右邊(列向量),實際運算是(行主序矩陣 X 列向量)。 就算這種方法也可以繪制出來,但還是很讓人難受,比第2點還難受,我甚至不想去說它。
也就是說,以組合1為基准,任意改變其中兩個狀態都不會影響最終結果。
DirectX11 With Windows SDK完整目錄
歡迎加入QQ群: 727623616 可以一起探討DX11,以及有什么問題也可以在這里匯報。
